From b80bc9948ab77c0efe667aa0e27e18f0e87ebe20 Mon Sep 17 00:00:00 2001 From: Corewala Date: Thu, 2 Dec 2021 13:11:34 -0500 Subject: [PATCH] Initial commit --- .gitignore | 4 + LICENSE | 305 +++++++++ README.md | 20 + app/local.properties | 4 + app/src/main/AndroidManifest.xml | 59 ++ app/src/main/java/corewala/Extensions.kt | 75 +++ app/src/main/java/corewala/buran/Buran.kt | 33 + app/src/main/java/corewala/buran/OmniTerm.kt | 100 +++ app/src/main/java/corewala/buran/OppenURI.kt | 107 ++++ .../main/java/corewala/buran/io/GemState.kt | 24 + .../io/database/BuranAbstractDatabase.kt | 14 + .../buran/io/database/BuranDatabase.kt | 14 + .../io/database/bookmarks/BookmarkEntity.kt | 16 + .../io/database/bookmarks/BookmarkEntry.kt | 12 + .../io/database/bookmarks/BookmarksDao.kt | 24 + .../database/bookmarks/BookmarksDatasource.kt | 13 + .../io/database/bookmarks/BuranBookmarks.kt | 95 +++ .../buran/io/database/history/BuranHistory.kt | 77 +++ .../buran/io/database/history/HistoryDao.kt | 27 + .../io/database/history/HistoryDatasource.kt | 12 + .../io/database/history/HistoryEntity.kt | 14 + .../buran/io/database/history/HistoryEntry.kt | 9 + .../corewala/buran/io/gemini/Datasource.kt | 19 + .../buran/io/gemini/DummyTrustManager.kt | 37 ++ .../buran/io/gemini/GeminiDatasource.kt | 245 ++++++++ .../buran/io/gemini/GeminiResponse.kt | 81 +++ .../corewala/buran/io/gemini/GemtextHelper.kt | 44 ++ .../buran/io/history/uris/BasicURIHistory.kt | 44 ++ .../buran/io/history/uris/HistoryInterface.kt | 15 + .../buran/io/keymanager/BuranKeyManager.kt | 67 ++ .../java/corewala/buran/ui/GemActivity.kt | 595 ++++++++++++++++++ .../java/corewala/buran/ui/GemViewModel.kt | 53 ++ .../corewala/buran/ui/ProcessTextActivity.kt | 24 + .../buran/ui/bookmarks/BookmarkDialog.kt | 82 +++ .../buran/ui/bookmarks/BookmarksAdapter.kt | 69 ++ .../buran/ui/bookmarks/BookmarksDialog.kt | 250 ++++++++ .../buran/ui/bookmarks/BookmarksViewModel.kt | 59 ++ .../buran/ui/content_image/ImageDialog.kt | 62 ++ .../ui/content_image/TouchImageView.java | 304 +++++++++ .../buran/ui/content_text/TextDialog.kt | 27 + .../AbstractGemtextAdapter.kt | 34 + .../gemtext_adapters/DefaultGemtextAdapter.kt | 256 ++++++++ .../ui/gemtext_adapters/GmiViewHolder.kt | 16 + .../gemtext_adapters/LargeGemtextAdapter.kt | 253 ++++++++ .../buran/ui/modals_menus/LinkPopup.kt | 34 + .../ui/modals_menus/about/AboutDialog.kt | 43 ++ .../ui/modals_menus/history/HistoryAdapter.kt | 30 + .../ui/modals_menus/history/HistoryDialog.kt | 64 ++ .../ui/modals_menus/input/InputDialog.kt | 33 + .../ui/modals_menus/overflow/OverflowPopup.kt | 64 ++ .../buran/ui/settings/SettingsActivity.kt | 25 + .../buran/ui/settings/SettingsFragment.kt | 455 ++++++++++++++ app/src/main/res/anim/fade_in.xml | 6 + .../res/drawable-anydpi-v26/laucher_round.xml | 4 + .../main/res/drawable-anydpi-v26/launcher.xml | 4 + .../main/res/drawable/block_background.xml | 6 + .../drawable/drawable_filled_rounded_rect.xml | 11 + .../drawable/drawable_stroke_rounded_rect.xml | 11 + app/src/main/res/drawable/launcher.xml | 4 + app/src/main/res/drawable/launcher_round.xml | 4 + app/src/main/res/drawable/vector_app_icon.xml | 161 +++++ app/src/main/res/drawable/vector_cancel.xml | 10 + .../main/res/drawable/vector_client_cert.xml | 10 + app/src/main/res/drawable/vector_close.xml | 10 + app/src/main/res/drawable/vector_code.xml | 10 + app/src/main/res/drawable/vector_confirm.xml | 10 + app/src/main/res/drawable/vector_home.xml | 10 + app/src/main/res/drawable/vector_link.xml | 10 + .../main/res/drawable/vector_open_browser.xml | 10 + app/src/main/res/drawable/vector_overflow.xml | 10 + app/src/main/res/drawable/vector_photo.xml | 10 + app/src/main/res/drawable/vector_refresh.xml | 10 + app/src/main/res/drawable/vector_save.xml | 13 + app/src/main/res/font/code_font.xml | 7 + app/src/main/res/font/jet_brains_mono.ttf | Bin 0 -> 114320 bytes app/src/main/res/layout/activity_gem.xml | 125 ++++ app/src/main/res/layout/activity_settings.xml | 20 + app/src/main/res/layout/bookmark.xml | 39 ++ app/src/main/res/layout/dialog_about.xml | 115 ++++ app/src/main/res/layout/dialog_bookmarks.xml | 54 ++ .../main/res/layout/dialog_content_image.xml | 43 ++ .../main/res/layout/dialog_content_text.xml | 50 ++ app/src/main/res/layout/dialog_history.xml | 40 ++ .../main/res/layout/dialog_input_query.xml | 62 ++ app/src/main/res/layout/dialog_set_home.xml | 30 + app/src/main/res/layout/dialog_tabs.xml | 43 ++ .../res/layout/fragment_bookmark_dialog.xml | 50 ++ .../main/res/layout/gemtext_code_block.xml | 56 ++ app/src/main/res/layout/gemtext_h1.xml | 14 + app/src/main/res/layout/gemtext_h2.xml | 14 + app/src/main/res/layout/gemtext_h3.xml | 14 + .../main/res/layout/gemtext_image_link.xml | 34 + .../res/layout/gemtext_large_code_block.xml | 58 ++ app/src/main/res/layout/gemtext_large_h1.xml | 14 + app/src/main/res/layout/gemtext_large_h2.xml | 14 + app/src/main/res/layout/gemtext_large_h3.xml | 14 + .../res/layout/gemtext_large_image_link.xml | 37 ++ .../main/res/layout/gemtext_large_link.xml | 26 + .../main/res/layout/gemtext_large_quote.xml | 18 + .../main/res/layout/gemtext_large_text.xml | 12 + app/src/main/res/layout/gemtext_link.xml | 23 + app/src/main/res/layout/gemtext_quote.xml | 18 + app/src/main/res/layout/gemtext_text.xml | 12 + app/src/main/res/layout/row_history.xml | 20 + app/src/main/res/menu/add_bookmark.xml | 8 + app/src/main/res/menu/add_bookmarks.xml | 4 + app/src/main/res/menu/audio_overflow.xml | 7 + .../main/res/menu/bookmark_import_export.xml | 11 + .../main/res/menu/history_overflow_menu.xml | 7 + app/src/main/res/menu/image_link_menu.xml | 7 + app/src/main/res/menu/image_overflow_menu.xml | 5 + app/src/main/res/menu/link_menu.xml | 5 + app/src/main/res/menu/menu_bookmark.xml | 11 + app/src/main/res/menu/overflow_menu.xml | 29 + app/src/main/res/raw/cert.pfx | Bin 0 -> 3941 bytes app/src/main/res/raw/colours.csv | 147 +++++ app/src/main/res/values-fr/strings.xml | 80 +++ app/src/main/res/values-night/colors.xml | 17 + app/src/main/res/values-night/styles.xml | 24 + app/src/main/res/values/colors.xml | 16 + app/src/main/res/values/dimens.xml | 37 ++ app/src/main/res/values/strings.xml | 80 +++ app/src/main/res/values/styles.xml | 34 + .../main/res/xml/network_security_config.xml | 4 + app/src/main/res/xml/provider_paths.xml | 4 + build.gradle | 22 + buran.svg | 426 +++++++++++++ gradle.properties | 21 + gradle/wrapper/gradle-wrapper.properties | 6 + local.properties | 11 + settings.gradle | 2 + 131 files changed, 6877 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 app/local.properties create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/corewala/Extensions.kt create mode 100644 app/src/main/java/corewala/buran/Buran.kt create mode 100644 app/src/main/java/corewala/buran/OmniTerm.kt create mode 100644 app/src/main/java/corewala/buran/OppenURI.kt create mode 100644 app/src/main/java/corewala/buran/io/GemState.kt create mode 100644 app/src/main/java/corewala/buran/io/database/BuranAbstractDatabase.kt create mode 100644 app/src/main/java/corewala/buran/io/database/BuranDatabase.kt create mode 100644 app/src/main/java/corewala/buran/io/database/bookmarks/BookmarkEntity.kt create mode 100644 app/src/main/java/corewala/buran/io/database/bookmarks/BookmarkEntry.kt create mode 100644 app/src/main/java/corewala/buran/io/database/bookmarks/BookmarksDao.kt create mode 100644 app/src/main/java/corewala/buran/io/database/bookmarks/BookmarksDatasource.kt create mode 100644 app/src/main/java/corewala/buran/io/database/bookmarks/BuranBookmarks.kt create mode 100644 app/src/main/java/corewala/buran/io/database/history/BuranHistory.kt create mode 100644 app/src/main/java/corewala/buran/io/database/history/HistoryDao.kt create mode 100644 app/src/main/java/corewala/buran/io/database/history/HistoryDatasource.kt create mode 100644 app/src/main/java/corewala/buran/io/database/history/HistoryEntity.kt create mode 100644 app/src/main/java/corewala/buran/io/database/history/HistoryEntry.kt create mode 100644 app/src/main/java/corewala/buran/io/gemini/Datasource.kt create mode 100644 app/src/main/java/corewala/buran/io/gemini/DummyTrustManager.kt create mode 100644 app/src/main/java/corewala/buran/io/gemini/GeminiDatasource.kt create mode 100644 app/src/main/java/corewala/buran/io/gemini/GeminiResponse.kt create mode 100644 app/src/main/java/corewala/buran/io/gemini/GemtextHelper.kt create mode 100644 app/src/main/java/corewala/buran/io/history/uris/BasicURIHistory.kt create mode 100644 app/src/main/java/corewala/buran/io/history/uris/HistoryInterface.kt create mode 100644 app/src/main/java/corewala/buran/io/keymanager/BuranKeyManager.kt create mode 100644 app/src/main/java/corewala/buran/ui/GemActivity.kt create mode 100644 app/src/main/java/corewala/buran/ui/GemViewModel.kt create mode 100644 app/src/main/java/corewala/buran/ui/ProcessTextActivity.kt create mode 100644 app/src/main/java/corewala/buran/ui/bookmarks/BookmarkDialog.kt create mode 100644 app/src/main/java/corewala/buran/ui/bookmarks/BookmarksAdapter.kt create mode 100644 app/src/main/java/corewala/buran/ui/bookmarks/BookmarksDialog.kt create mode 100644 app/src/main/java/corewala/buran/ui/bookmarks/BookmarksViewModel.kt create mode 100644 app/src/main/java/corewala/buran/ui/content_image/ImageDialog.kt create mode 100644 app/src/main/java/corewala/buran/ui/content_image/TouchImageView.java create mode 100644 app/src/main/java/corewala/buran/ui/content_text/TextDialog.kt create mode 100644 app/src/main/java/corewala/buran/ui/gemtext_adapters/AbstractGemtextAdapter.kt create mode 100644 app/src/main/java/corewala/buran/ui/gemtext_adapters/DefaultGemtextAdapter.kt create mode 100644 app/src/main/java/corewala/buran/ui/gemtext_adapters/GmiViewHolder.kt create mode 100644 app/src/main/java/corewala/buran/ui/gemtext_adapters/LargeGemtextAdapter.kt create mode 100644 app/src/main/java/corewala/buran/ui/modals_menus/LinkPopup.kt create mode 100644 app/src/main/java/corewala/buran/ui/modals_menus/about/AboutDialog.kt create mode 100644 app/src/main/java/corewala/buran/ui/modals_menus/history/HistoryAdapter.kt create mode 100644 app/src/main/java/corewala/buran/ui/modals_menus/history/HistoryDialog.kt create mode 100644 app/src/main/java/corewala/buran/ui/modals_menus/input/InputDialog.kt create mode 100644 app/src/main/java/corewala/buran/ui/modals_menus/overflow/OverflowPopup.kt create mode 100644 app/src/main/java/corewala/buran/ui/settings/SettingsActivity.kt create mode 100644 app/src/main/java/corewala/buran/ui/settings/SettingsFragment.kt create mode 100644 app/src/main/res/anim/fade_in.xml create mode 100644 app/src/main/res/drawable-anydpi-v26/laucher_round.xml create mode 100644 app/src/main/res/drawable-anydpi-v26/launcher.xml create mode 100644 app/src/main/res/drawable/block_background.xml create mode 100644 app/src/main/res/drawable/drawable_filled_rounded_rect.xml create mode 100644 app/src/main/res/drawable/drawable_stroke_rounded_rect.xml create mode 100644 app/src/main/res/drawable/launcher.xml create mode 100644 app/src/main/res/drawable/launcher_round.xml create mode 100644 app/src/main/res/drawable/vector_app_icon.xml create mode 100644 app/src/main/res/drawable/vector_cancel.xml create mode 100644 app/src/main/res/drawable/vector_client_cert.xml create mode 100644 app/src/main/res/drawable/vector_close.xml create mode 100644 app/src/main/res/drawable/vector_code.xml create mode 100644 app/src/main/res/drawable/vector_confirm.xml create mode 100644 app/src/main/res/drawable/vector_home.xml create mode 100644 app/src/main/res/drawable/vector_link.xml create mode 100644 app/src/main/res/drawable/vector_open_browser.xml create mode 100644 app/src/main/res/drawable/vector_overflow.xml create mode 100644 app/src/main/res/drawable/vector_photo.xml create mode 100644 app/src/main/res/drawable/vector_refresh.xml create mode 100644 app/src/main/res/drawable/vector_save.xml create mode 100644 app/src/main/res/font/code_font.xml create mode 100644 app/src/main/res/font/jet_brains_mono.ttf create mode 100644 app/src/main/res/layout/activity_gem.xml create mode 100644 app/src/main/res/layout/activity_settings.xml create mode 100644 app/src/main/res/layout/bookmark.xml create mode 100644 app/src/main/res/layout/dialog_about.xml create mode 100644 app/src/main/res/layout/dialog_bookmarks.xml create mode 100644 app/src/main/res/layout/dialog_content_image.xml create mode 100644 app/src/main/res/layout/dialog_content_text.xml create mode 100644 app/src/main/res/layout/dialog_history.xml create mode 100644 app/src/main/res/layout/dialog_input_query.xml create mode 100644 app/src/main/res/layout/dialog_set_home.xml create mode 100644 app/src/main/res/layout/dialog_tabs.xml create mode 100644 app/src/main/res/layout/fragment_bookmark_dialog.xml create mode 100644 app/src/main/res/layout/gemtext_code_block.xml create mode 100644 app/src/main/res/layout/gemtext_h1.xml create mode 100644 app/src/main/res/layout/gemtext_h2.xml create mode 100644 app/src/main/res/layout/gemtext_h3.xml create mode 100644 app/src/main/res/layout/gemtext_image_link.xml create mode 100644 app/src/main/res/layout/gemtext_large_code_block.xml create mode 100644 app/src/main/res/layout/gemtext_large_h1.xml create mode 100644 app/src/main/res/layout/gemtext_large_h2.xml create mode 100644 app/src/main/res/layout/gemtext_large_h3.xml create mode 100644 app/src/main/res/layout/gemtext_large_image_link.xml create mode 100644 app/src/main/res/layout/gemtext_large_link.xml create mode 100644 app/src/main/res/layout/gemtext_large_quote.xml create mode 100644 app/src/main/res/layout/gemtext_large_text.xml create mode 100644 app/src/main/res/layout/gemtext_link.xml create mode 100644 app/src/main/res/layout/gemtext_quote.xml create mode 100644 app/src/main/res/layout/gemtext_text.xml create mode 100644 app/src/main/res/layout/row_history.xml create mode 100644 app/src/main/res/menu/add_bookmark.xml create mode 100644 app/src/main/res/menu/add_bookmarks.xml create mode 100644 app/src/main/res/menu/audio_overflow.xml create mode 100644 app/src/main/res/menu/bookmark_import_export.xml create mode 100644 app/src/main/res/menu/history_overflow_menu.xml create mode 100644 app/src/main/res/menu/image_link_menu.xml create mode 100644 app/src/main/res/menu/image_overflow_menu.xml create mode 100644 app/src/main/res/menu/link_menu.xml create mode 100644 app/src/main/res/menu/menu_bookmark.xml create mode 100644 app/src/main/res/menu/overflow_menu.xml create mode 100644 app/src/main/res/raw/cert.pfx create mode 100644 app/src/main/res/raw/colours.csv create mode 100644 app/src/main/res/values-fr/strings.xml create mode 100644 app/src/main/res/values-night/colors.xml create mode 100644 app/src/main/res/values-night/styles.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/dimens.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/styles.xml create mode 100644 app/src/main/res/xml/network_security_config.xml create mode 100644 app/src/main/res/xml/provider_paths.xml create mode 100644 build.gradle create mode 100644 buran.svg create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 local.properties create mode 100644 settings.gradle diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2a5294e --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.gradle +.idea +build +release \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c108cad --- /dev/null +++ b/LICENSE @@ -0,0 +1,305 @@ +European Union Public Licence v. 1.2 + +EUPL © the European Union 2007, 2016 + +This European Union Public Licence (the 'EUPL') applies to the Work (as defined +below) which is provided under the terms of this Licence. Any use of the Work, +other than as authorised under this Licence is prohibited (to the extent such +use is covered by a right of the copyright holder of the Work). + +The Work is provided under the terms of this Licence when the Licensor (as +defined below) has placed the following notice immediately following the copyright +notice for the Work: + + + + Licensed under the EUPL + + + +or has expressed by any other means his willingness to license under the EUPL. + + 1. Definitions + + In this Licence, the following terms have the following meaning: + + — 'The Licence': this Licence. + +— 'The Original Work': the work or software distributed or communicated by +the Licensor under this Licence, available as Source Code and also as Executable +Code as the case may be. + +— 'Derivative Works': the works or software that could be created by the Licensee, +based upon the Original Work or modifications thereof. This Licence does not +define the extent of modification or dependence on the Original Work required +in order to classify a work as a Derivative Work; this extent is determined +by copyright law applicable in the country mentioned in Article 15. + + — 'The Work': the Original Work or its Derivative Works. + +— 'The Source Code': the human-readable form of the Work which is the most +convenient for people to study and modify. + +— 'The Executable Code': any code which has generally been compiled and which +is meant to be interpreted by a computer as a program. + +— 'The Licensor': the natural or legal person that distributes or communicates +the Work under the Licence. + +— 'Contributor(s)': any natural or legal person who modifies the Work under +the Licence, or otherwise contributes to the creation of a Derivative Work. + +— 'The Licensee' or 'You': any natural or legal person who makes any usage +of the Work under the terms of the Licence. + +— 'Distribution' or 'Communication': any act of selling, giving, lending, +renting, distributing, communicating, transmitting, or otherwise making available, +online or offline, copies of the Work or providing access to its essential +functionalities at the disposal of any other natural or legal person. + + 2. Scope of the rights granted by the Licence + +The Licensor hereby grants You a worldwide, royalty-free, non-exclusive, sublicensable +licence to do the following, for the duration of copyright vested in the Original +Work: + + — use the Work in any circumstance and for all usage, + + — reproduce the Work, + + — modify the Work, and make Derivative Works based upon the Work, + +— communicate to the public, including the right to make available or display +the Work or copies thereof to the public and perform publicly, as the case +may be, the Work, + + — distribute the Work or copies thereof, + + — lend and rent the Work or copies thereof, + + — sublicense rights in the Work or copies thereof. + +Those rights can be exercised on any media, supports and formats, whether +now known or later invented, as far as the applicable law permits so. + +In the countries where moral rights apply, the Licensor waives his right to +exercise his moral right to the extent allowed by law in order to make effective +the licence of the economic rights here above listed. + +The Licensor grants to the Licensee royalty-free, non-exclusive usage rights +to any patents held by the Licensor, to the extent necessary to make use of +the rights granted on the Work under this Licence. + + 3. Communication of the Source Code + +The Licensor may provide the Work either in its Source Code form, or as Executable +Code. If the Work is provided as Executable Code, the Licensor provides in +addition a machine-readable copy of the Source Code of the Work along with +each copy of the Work that the Licensor distributes or indicates, in a notice +following the copyright notice attached to the Work, a repository where the +Source Code is easily and freely accessible for as long as the Licensor continues +to distribute or communicate the Work. + + 4. Limitations on copyright + +Nothing in this Licence is intended to deprive the Licensee of the benefits +from any exception or limitation to the exclusive rights of the rights owners +in the Work, of the exhaustion of those rights or of other applicable limitations +thereto. + + 5. Obligations of the Licensee + +The grant of the rights mentioned above is subject to some restrictions and +obligations imposed on the Licensee. Those obligations are the following: + +Attribution right: The Licensee shall keep intact all copyright, patent or +trademarks notices and all notices that refer to the Licence and to the disclaimer +of warranties. The Licensee must include a copy of such notices and a copy +of the Licence with every copy of the Work he/she distributes or communicates. +The Licensee must cause any Derivative Work to carry prominent notices stating +that the Work has been modified and the date of modification. + +Copyleft clause: If the Licensee distributes or communicates copies of the +Original Works or Derivative Works, this Distribution or Communication will +be done under the terms of this Licence or of a later version of this Licence +unless the Original Work is expressly distributed only under this version +of the Licence — for example by communicating 'EUPL v. 1.2 only'. The Licensee +(becoming Licensor) cannot offer or impose any additional terms or conditions +on the Work or Derivative Work that alter or restrict the terms of the Licence. + +Compatibility clause: If the Licensee Distributes or Communicates Derivative +Works or copies thereof based upon both the Work and another work licensed +under a Compatible Licence, this Distribution or Communication can be done +under the terms of this Compatible Licence. For the sake of this clause, 'Compatible +Licence' refers to the licences listed in the appendix attached to this Licence. +Should the Licensee's obligations under the Compatible Licence conflict with +his/her obligations under this Licence, the obligations of the Compatible +Licence shall prevail. + +Provision of Source Code: When distributing or communicating copies of the +Work, the Licensee will provide a machine-readable copy of the Source Code +or indicate a repository where this Source will be easily and freely available +for as long as the Licensee continues to distribute or communicate the Work. + +Legal Protection: This Licence does not grant permission to use the trade +names, trademarks, service marks, or names of the Licensor, except as required +for reasonable and customary use in describing the origin of the Work and +reproducing the content of the copyright notice. + + 6. Chain of Authorship + +The original Licensor warrants that the copyright in the Original Work granted +hereunder is owned by him/her or licensed to him/her and that he/she has the +power and authority to grant the Licence. + +Each Contributor warrants that the copyright in the modifications he/she brings +to the Work are owned by him/her or licensed to him/her and that he/she has +the power and authority to grant the Licence. + +Each time You accept the Licence, the original Licensor and subsequent Contributors +grant You a licence to their contributions to the Work, under the terms of +this Licence. + + 7. Disclaimer of Warranty + +The Work is a work in progress, which is continuously improved by numerous +Contributors. It is not a finished work and may therefore contain defects +or 'bugs' inherent to this type of development. + +For the above reason, the Work is provided under the Licence on an 'as is' +basis and without warranties of any kind concerning the Work, including without +limitation merchantability, fitness for a particular purpose, absence of defects +or errors, accuracy, non-infringement of intellectual property rights other +than copyright as stated in Article 6 of this Licence. + +This disclaimer of warranty is an essential part of the Licence and a condition +for the grant of any rights to the Work. + + 8. Disclaimer of Liability + +Except in the cases of wilful misconduct or damages directly caused to natural +persons, the Licensor will in no event be liable for any direct or indirect, +material or moral, damages of any kind, arising out of the Licence or of the +use of the Work, including without limitation, damages for loss of goodwill, +work stoppage, computer failure or malfunction, loss of data or any commercial +damage, even if the Licensor has been advised of the possibility of such damage. +However, the Licensor will be liable under statutory product liability laws +as far such laws apply to the Work. + + 9. Additional agreements + +While distributing the Work, You may choose to conclude an additional agreement, +defining obligations or services consistent with this Licence. However, if +accepting obligations, You may act only on your own behalf and on your sole +responsibility, not on behalf of the original Licensor or any other Contributor, +and only if You agree to indemnify, defend, and hold each Contributor harmless +for any liability incurred by, or claims asserted against such Contributor +by the fact You have accepted any warranty or additional liability. + + 10. Acceptance of the Licence + +The provisions of this Licence can be accepted by clicking on an icon 'I agree' +placed under the bottom of a window displaying the text of this Licence or +by affirming consent in any other similar way, in accordance with the rules +of applicable law. Clicking on that icon indicates your clear and irrevocable +acceptance of this Licence and all of its terms and conditions. + +Similarly, you irrevocably accept this Licence and all of its terms and conditions +by exercising any rights granted to You by Article 2 of this Licence, such +as the use of the Work, the creation by You of a Derivative Work or the Distribution +or Communication by You of the Work or copies thereof. + + 11. Information to the public + +In case of any Distribution or Communication of the Work by means of electronic +communication by You (for example, by offering to download the Work from a +remote location) the distribution channel or media (for example, a website) +must at least provide to the public the information requested by the applicable +law regarding the Licensor, the Licence and the way it may be accessible, +concluded, stored and reproduced by the Licensee. + + 12. Termination of the Licence + +The Licence and the rights granted hereunder will terminate automatically +upon any breach by the Licensee of the terms of the Licence. + +Such a termination will not terminate the licences of any person who has received +the Work from the Licensee under the Licence, provided such persons remain +in full compliance with the Licence. + + 13. Miscellaneous + +Without prejudice of Article 9 above, the Licence represents the complete +agreement between the Parties as to the Work. + +If any provision of the Licence is invalid or unenforceable under applicable +law, this will not affect the validity or enforceability of the Licence as +a whole. Such provision will be construed or reformed so as necessary to make +it valid and enforceable. + +The European Commission may publish other linguistic versions or new versions +of this Licence or updated versions of the Appendix, so far this is required +and reasonable, without reducing the scope of the rights granted by the Licence. +New versions of the Licence will be published with a unique version number. + +All linguistic versions of this Licence, approved by the European Commission, +have identical value. Parties can take advantage of the linguistic version +of their choice. + + 14. Jurisdiction + + Without prejudice to specific agreement between parties, + +— any litigation resulting from the interpretation of this License, arising +between the European Union institutions, bodies, offices or agencies, as a +Licensor, and any Licensee, will be subject to the jurisdiction of the Court +of Justice of the European Union, as laid down in article 272 of the Treaty +on the Functioning of the European Union, + +— any litigation arising between other parties and resulting from the interpretation +of this License, will be subject to the exclusive jurisdiction of the competent +court where the Licensor resides or conducts its primary business. + + 15. Applicable Law + + Without prejudice to specific agreement between parties, + +— this Licence shall be governed by the law of the European Union Member State +where the Licensor has his seat, resides or has his registered office, + +— this licence shall be governed by Belgian law if the Licensor has no seat, +residence or registered office inside a European Union Member State. + +Appendix + +'Compatible Licences' according to Article 5 EUPL are: + + — GNU General Public License (GPL) v. 2, v. 3 + + — GNU Affero General Public License (AGPL) v. 3 + + — Open Software License (OSL) v. 2.1, v. 3.0 + + — Eclipse Public License (EPL) v. 1.0 + + — CeCILL v. 2.0, v. 2.1 + + — Mozilla Public Licence (MPL) v. 2 + + — GNU Lesser General Public Licence (LGPL) v. 2.1, v. 3 + +— Creative Commons Attribution-ShareAlike v. 3.0 Unported (CC BY-SA 3.0) for +works other than software + + — European Union Public Licence (EUPL) v. 1.1, v. 1.2 + +— Québec Free and Open-Source Licence — Reciprocity (LiLiQ-R) or Strong Reciprocity +(LiLiQ-R+). + +The European Commission may update this Appendix to later versions of the +above licences without producing a new version of the EUPL, as long as they +provide the rights granted in Article 2 of this Licence and protect the covered +Source Code from exclusive appropriation. + +All other changes or additions to this Appendix require the production of +a new EUPL version. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ede6ec4 --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +# Buran + + + +[![forthebadge](https://forthebadge.com/images/badges/built-for-android.svg)](https://github.com/Corewala/Buran#buran) +[![forthebadge](https://forthebadge.com/images/badges/as-seen-on-tv.svg)](https://github.com/Corewala/Buran#buran) + +[![shields](https://img.shields.io/badge/Download-Here-orange?style=for-the-badge&logo=github)](https://github.com/Corewala/Buran/releases/latest) + +Buran is a simple Gemini protocol browser for Android. + +It is currently a quite minimal fork of the Ariane browser, but I hope to implement more features in the future. + +## Credits + +Buran is based on the [Ariane source code](https://web.archive.org/web/20210920212507/https://codeberg.org/oppenlab/Ariane) (now [Seren](https://orllewin.uk/)), created by ÖLAB. + +The font used in code blocks is [JetBrains Mono](https://www.jetbrains.com/lp/mono/), created by JetBrains. + +The glyphs used throughout the project are from [Material Icons](https://fonts.google.com/icons), created by Google. \ No newline at end of file diff --git a/app/local.properties b/app/local.properties new file mode 100644 index 0000000..cf738a7 --- /dev/null +++ b/app/local.properties @@ -0,0 +1,4 @@ +sdk.dir=/home/vagrant/android-sdk +sdk-location=/home/vagrant/android-sdk +ndk.dir=/home/vagrant/android-ndk/r12b +ndk-location=/home/vagrant/android-ndk/r12b diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..8e221a6 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/corewala/Extensions.kt b/app/src/main/java/corewala/Extensions.kt new file mode 100644 index 0000000..09b6d73 --- /dev/null +++ b/app/src/main/java/corewala/Extensions.kt @@ -0,0 +1,75 @@ +package corewala + +import android.annotation.SuppressLint +import android.content.Context +import android.content.res.Resources +import android.net.Uri +import android.os.CountDownTimer +import android.view.View +import android.view.inputmethod.InputMethodManager +import java.net.URI + + +fun View.visible(visible: Boolean) = when { + visible -> this.visibility = View.VISIBLE + else -> this.visibility = View.GONE +} + +fun View.visibleRetainingSpace(visible: Boolean) = when { + visible -> this.visibility = View.VISIBLE + else -> this.visibility = View.INVISIBLE +} + +fun View.hideKeyboard(){ + val imm: InputMethodManager? = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager? + imm?.hideSoftInputFromWindow(windowToken, 0) +} + +fun View.showKeyboard(){ + val imm: InputMethodManager? = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager? + imm?.toggleSoftInput(InputMethodManager.SHOW_FORCED, 0) +} + +fun String.toURI(): URI { + return URI.create(this) +} + +fun URI.toUri(): Uri { + return Uri.parse(this.toString()) +} + +fun Uri.toURI(): URI { + return URI.create(this.toString()) +} + +fun Uri.isGemini(): Boolean{ + return this.toString().startsWith("gemini://") +} + +@SuppressLint("DefaultLocale") +fun String.endsWithImage(): Boolean{ + return this.toLowerCase().endsWith(".png") || + this.toLowerCase().endsWith(".jpg") || + this.toLowerCase().endsWith(".jpeg") || + this.toLowerCase().endsWith(".gif") +} + +@SuppressLint("DefaultLocale") +fun String.isWeb(): Boolean{ + return this.toLowerCase().startsWith("https://") || + this.toLowerCase().startsWith("http://") +} + +fun delay(ms: Long, action: () -> Unit){ + object : CountDownTimer(ms, ms/2) { + override fun onTick(millisUntilFinished: Long) {} + + override fun onFinish() { + action.invoke() + } + }.start() +} + +fun Int.toPx(): Float { + return (this.toFloat() * Resources.getSystem().displayMetrics.density) +} diff --git a/app/src/main/java/corewala/buran/Buran.kt b/app/src/main/java/corewala/buran/Buran.kt new file mode 100644 index 0000000..aeec3ca --- /dev/null +++ b/app/src/main/java/corewala/buran/Buran.kt @@ -0,0 +1,33 @@ +package corewala.buran + +import android.app.Application +import androidx.appcompat.app.AppCompatDelegate +import androidx.preference.PreferenceManager + +class Buran: Application() { + + override fun onCreate() { + super.onCreate() + + val prefs = PreferenceManager.getDefaultSharedPreferences(this) + + when { + prefs.getBoolean("theme_Light", false) -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) + prefs.getBoolean("theme_Dark", false) -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) + prefs.getBoolean("theme_FollowSystem", true) -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) + } + + } + + companion object{ + const val DEFAULT_HOME_CAPSULE = "gemini://rawtext.club/~sloum/spacewalk.gmi" + + const val FEATURE_CLIENT_CERTS = true + + const val PREF_KEY_CLIENT_CERT_URI = "client_cert_uri" + const val PREF_KEY_CLIENT_CERT_HUMAN_READABLE = "client_cert_uri_human_readable" + const val PREF_KEY_CLIENT_CERT_ACTIVE = "client_cert_active" + const val PREF_KEY_CLIENT_CERT_PASSWORD = "client_cert_password" + const val PREF_KEY_USE_CUSTOM_TAB = "use_custom_tabs" + } +} \ No newline at end of file diff --git a/app/src/main/java/corewala/buran/OmniTerm.kt b/app/src/main/java/corewala/buran/OmniTerm.kt new file mode 100644 index 0000000..3da8cb8 --- /dev/null +++ b/app/src/main/java/corewala/buran/OmniTerm.kt @@ -0,0 +1,100 @@ +package corewala.buran + +import android.net.Uri +import java.util.* + +const val GEM_SCHEME = "gemini://" +const val GUS_SEARCH_BASE = "gemini://geminispace.info/search?" + +class OmniTerm(private val listener: Listener) { + val history = ArrayList() + var uri = OppenURI() + var penultimate = OppenURI() + + /** + * User input to the 'omni bar' - could be an address or a search term + * @param term - User-inputted term + */ + fun input(term: String){ + when { + term.startsWith(GEM_SCHEME) && term != GEM_SCHEME -> { + listener.request(term) + return + } + term.contains(".") -> { + listener.request("gemini://${term}") + } + else -> { + val encoded = Uri.encode(term) + listener.request("$GUS_SEARCH_BASE$encoded") + } + } + } + + fun search(term: String){ + val encoded = Uri.encode(term) + listener.request("$GUS_SEARCH_BASE$encoded") + } + + + fun navigation(link: String) { + navigation(link, true) + } + + fun imageAddress(link: String){ + navigation(link, false) + } + + /** + * A clicked link, could be absolute or relative + * @param link - a Gemtext link + */ + private fun navigation(link: String, invokeListener: Boolean) { + when { + link.startsWith("http") -> listener.openBrowser(link) + link.startsWith(GEM_SCHEME) -> uri.set(link) + link.startsWith("//") -> uri.set("gemini:$link") + else -> uri.resolve(link) + } + + //todo - fix this, the double slash fix breaks the scheme, so this hack puts it back... uggh + val address = uri.toString().replace("%2F", "/").replace("//", "/").replace("gemini:/", "gemini://") + println("OmniTerm resolved address: $address") + + if(invokeListener) listener.request(address) + } + + fun traverse(address: String): String { + return OppenURI(address).traverse().toString() + } + + fun reset(){ + uri = penultimate.copy() + } + + fun set(address: String) { + penultimate.set(address) + uri.set(address) + if (history.isEmpty() || history.last().toString() != address) { + history.add(uri.copy()) + } + } + + fun getCurrent(): String { + return history.last().toString() + } + + fun canGoBack(): Boolean { + return history.size > 1 + } + + fun goBack(): String { + history.removeLast() + return history.last().toString() + } + + interface Listener{ + fun request(address: String) + fun openBrowser(address: String) + } +} \ No newline at end of file diff --git a/app/src/main/java/corewala/buran/OppenURI.kt b/app/src/main/java/corewala/buran/OppenURI.kt new file mode 100644 index 0000000..6d88f9a --- /dev/null +++ b/app/src/main/java/corewala/buran/OppenURI.kt @@ -0,0 +1,107 @@ +package corewala.buran + +const val SCHEME = "gemini://" +const val TRAVERSE = "../" +const val SOLIDUS = "/" +const val DIREND = "/" + +/** + * + * Easy uri path handling for Gemini + * + */ +class OppenURI constructor(private var ouri: String) { + + constructor(): this("") + + var host: String = "" + + init { + extractHost() + } + + fun set(ouri: String){ + this.ouri = ouri + extractHost() + } + + fun resolve(reference: String) { + if(ouri == "$SCHEME$host") ouri = "$ouri/" + return when { + reference.startsWith(SCHEME) -> set(reference) + reference.startsWith(SOLIDUS) -> ouri = "$SCHEME$host$reference" + reference.startsWith(TRAVERSE) -> { + if(!ouri.endsWith(DIREND)) ouri = ouri.removeFile() + val traversalCount = reference.split(TRAVERSE).size - 1 + ouri = traverse(traversalCount) + reference.replace(TRAVERSE, "") + } + else -> { + ouri = when { + ouri.endsWith(DIREND) -> "${ouri}$reference" + else -> "${ouri.substring(0, ouri.lastIndexOf("/"))}/$reference" + } + } + } + } + + fun traverse(): OppenURI{ + val path = ouri.removePrefix("$SCHEME$host") + val segments = path.split(SOLIDUS).filter { it.isNotEmpty() } + + var nouri = "$SCHEME$host" + + when (ouri) { + "" -> { + } + SCHEME -> ouri = "" + "$nouri/" -> ouri = SCHEME + else -> { + when { + segments.isNotEmpty() -> { + val remaining = segments.dropLast(1) + remaining.forEach { segment -> + nouri += "/$segment" + } + ouri = "$nouri/" + } + else -> ouri = "$nouri/" + } + } + } + + return this + } + + private fun traverse(count: Int): String{ + val path = ouri.removePrefix("$SCHEME$host") + val segments = path.split(SOLIDUS).filter { it.isNotEmpty() } + val segmentCount = segments.size + var nouri = "$SCHEME$host" + + segments.forEachIndexed{ index, segment -> + if(index < segmentCount - count){ + nouri += "/$segment" + } + } + + return "$nouri/" + + } + + private fun extractHost(){ + if(ouri.isEmpty()) return + val urn = ouri.removePrefix(SCHEME) + host = when { + urn.contains(SOLIDUS) -> urn.substring(0, urn.indexOf(SOLIDUS)) + else -> urn + } + } + + fun copy(): OppenURI = OppenURI(ouri) + + override fun toString(): String = ouri + + private fun String.removeFile(): String{ + return this.substring(0, ouri.lastIndexOf("/") + 1) + } +} diff --git a/app/src/main/java/corewala/buran/io/GemState.kt b/app/src/main/java/corewala/buran/io/GemState.kt new file mode 100644 index 0000000..3c97f9f --- /dev/null +++ b/app/src/main/java/corewala/buran/io/GemState.kt @@ -0,0 +1,24 @@ +package corewala.buran.io + +import android.net.Uri +import corewala.buran.io.gemini.GeminiResponse +import java.net.URI + +sealed class GemState { + data class AppQuery(val uri: URI): GemState() + data class Requesting(val uri: URI): GemState() + data class NotGeminiRequest(val uri: URI) : GemState() + + data class ResponseGemtext(val uri: URI, val header: GeminiResponse.Header, val lines: List) : GemState() + data class ResponseInput(val uri: URI, val header: GeminiResponse.Header) : GemState() + data class ResponseText(val uri: URI, val header: GeminiResponse.Header, val content: String) : GemState() + data class ResponseImage(val uri: URI, val header: GeminiResponse.Header, val cacheUri: Uri) : GemState() + data class ResponseBinary(val uri: URI, val header: GeminiResponse.Header, val cacheUri: Uri) : GemState() + data class ResponseUnknownMime(val uri: URI, val header: GeminiResponse.Header) : GemState() + data class ResponseError(val header: GeminiResponse.Header): GemState() + data class ResponseUnknownHost(val uri: URI): GemState() + + data class ClientCertError(val header: GeminiResponse.Header): GemState() + + object Blank: GemState() +} \ No newline at end of file diff --git a/app/src/main/java/corewala/buran/io/database/BuranAbstractDatabase.kt b/app/src/main/java/corewala/buran/io/database/BuranAbstractDatabase.kt new file mode 100644 index 0000000..c6ceff9 --- /dev/null +++ b/app/src/main/java/corewala/buran/io/database/BuranAbstractDatabase.kt @@ -0,0 +1,14 @@ +package corewala.buran.io.database + +import androidx.room.Database +import androidx.room.RoomDatabase +import corewala.buran.io.database.bookmarks.BookmarkEntity +import corewala.buran.io.database.bookmarks.BookmarksDao +import corewala.buran.io.database.history.HistoryDao +import corewala.buran.io.database.history.HistoryEntity + +@Database(entities = [BookmarkEntity::class, HistoryEntity::class], version = 3) +abstract class BuranAbstractDatabase: RoomDatabase() { + abstract fun bookmarks(): BookmarksDao + abstract fun history(): HistoryDao +} \ No newline at end of file diff --git a/app/src/main/java/corewala/buran/io/database/BuranDatabase.kt b/app/src/main/java/corewala/buran/io/database/BuranDatabase.kt new file mode 100644 index 0000000..56abc71 --- /dev/null +++ b/app/src/main/java/corewala/buran/io/database/BuranDatabase.kt @@ -0,0 +1,14 @@ +package corewala.buran.io.database + +import android.content.Context +import androidx.room.Room +import corewala.buran.io.database.bookmarks.BuranBookmarks +import corewala.buran.io.database.history.BuranHistory + +class BuranDatabase(context: Context) { + + private val db: BuranAbstractDatabase = Room.databaseBuilder(context, BuranAbstractDatabase::class.java, "buran_database_v1").build() + + fun bookmarks(): BuranBookmarks = BuranBookmarks(db) + fun history(): BuranHistory = BuranHistory(db) +} \ No newline at end of file diff --git a/app/src/main/java/corewala/buran/io/database/bookmarks/BookmarkEntity.kt b/app/src/main/java/corewala/buran/io/database/bookmarks/BookmarkEntity.kt new file mode 100644 index 0000000..9dd0b87 --- /dev/null +++ b/app/src/main/java/corewala/buran/io/database/bookmarks/BookmarkEntity.kt @@ -0,0 +1,16 @@ +package corewala.buran.io.database.bookmarks + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "bookmarks") +class BookmarkEntity( + @ColumnInfo(name = "label") val label: String?, + @ColumnInfo(name = "uri") val uri: String?, + @ColumnInfo(name = "uiIndex") val uiIndex: Int?, + @ColumnInfo(name = "folder") val folder: String? +){ + @PrimaryKey(autoGenerate = true) + var uid: Int = 0 +} \ No newline at end of file diff --git a/app/src/main/java/corewala/buran/io/database/bookmarks/BookmarkEntry.kt b/app/src/main/java/corewala/buran/io/database/bookmarks/BookmarkEntry.kt new file mode 100644 index 0000000..bf09657 --- /dev/null +++ b/app/src/main/java/corewala/buran/io/database/bookmarks/BookmarkEntry.kt @@ -0,0 +1,12 @@ +package corewala.buran.io.database.bookmarks + +import java.net.URI + +class BookmarkEntry( + val uid: Int, + val label: String, + val uri: URI, + val index: Int +){ + var visible = true +} \ No newline at end of file diff --git a/app/src/main/java/corewala/buran/io/database/bookmarks/BookmarksDao.kt b/app/src/main/java/corewala/buran/io/database/bookmarks/BookmarksDao.kt new file mode 100644 index 0000000..eed50cd --- /dev/null +++ b/app/src/main/java/corewala/buran/io/database/bookmarks/BookmarksDao.kt @@ -0,0 +1,24 @@ +package corewala.buran.io.database.bookmarks + +import androidx.room.* + +@Dao +interface BookmarksDao { + @Query("SELECT * FROM bookmarks ORDER BY uiIndex ASC") + suspend fun getAll(): List + + @Query("SELECT * from bookmarks WHERE uiIndex = :index LIMIT 1") + suspend fun getBookmark(index: Int): BookmarkEntity + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(bookmarks: Array) + + @Query("UPDATE bookmarks SET uiIndex=:index WHERE uid = :id") + fun updateUIIndex(id: Int, index: Int) + + @Query("UPDATE bookmarks SET label=:label, uri=:uri WHERE uid = :id") + fun updateContent(id: Int, label: String, uri: String) + + @Delete + suspend fun delete(bookmark: BookmarkEntity) +} \ No newline at end of file diff --git a/app/src/main/java/corewala/buran/io/database/bookmarks/BookmarksDatasource.kt b/app/src/main/java/corewala/buran/io/database/bookmarks/BookmarksDatasource.kt new file mode 100644 index 0000000..cafbc89 --- /dev/null +++ b/app/src/main/java/corewala/buran/io/database/bookmarks/BookmarksDatasource.kt @@ -0,0 +1,13 @@ +package corewala.buran.io.database.bookmarks + +interface BookmarksDatasource { + + fun get(onBookmarks: (List) -> Unit) + fun add(bookmarkEntry: BookmarkEntry, onAdded: () -> Unit) + fun add(bookmarkEntries: Array, onAdded: () -> Unit) + fun delete(bookmarkEntry: BookmarkEntry, onDelete: () -> Unit) + + fun moveUp(bookmarkEntry: BookmarkEntry, onMoved: () -> Unit) + fun moveDown(bookmarkEntry: BookmarkEntry, onMoved: () -> Unit) + fun update(bookmarkEntry: BookmarkEntry, label: String?, uri: String?, onUpdate: () -> Unit) +} \ No newline at end of file diff --git a/app/src/main/java/corewala/buran/io/database/bookmarks/BuranBookmarks.kt b/app/src/main/java/corewala/buran/io/database/bookmarks/BuranBookmarks.kt new file mode 100644 index 0000000..5fd39a4 --- /dev/null +++ b/app/src/main/java/corewala/buran/io/database/bookmarks/BuranBookmarks.kt @@ -0,0 +1,95 @@ +package corewala.buran.io.database.bookmarks + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import corewala.buran.io.database.BuranAbstractDatabase +import java.net.URI + +class BuranBookmarks(private val db: BuranAbstractDatabase): BookmarksDatasource { + + override fun get(onBookmarks: (List) -> Unit) { + GlobalScope.launch(Dispatchers.IO){ + val dbBookmarks = db.bookmarks().getAll() + val bookmarks = mutableListOf() + + dbBookmarks.forEach { bookmarkEntity -> + bookmarks.add( + BookmarkEntry( + uid = bookmarkEntity.uid, + label = bookmarkEntity.label ?: "Unknown", + uri = URI.create(bookmarkEntity.uri), + index = bookmarkEntity.uiIndex ?: 0) + ) + } + onBookmarks(bookmarks) + } + } + + override fun add(bookmarkEntry: BookmarkEntry, onAdded: () -> Unit) { + GlobalScope.launch(Dispatchers.IO){ + val bookmarkEntity = BookmarkEntity( + label = bookmarkEntry.label, + uri = bookmarkEntry.uri.toString(), + uiIndex = bookmarkEntry.index, + folder = "~/") + + db.bookmarks().insertAll(arrayOf(bookmarkEntity)) + onAdded() + } + } + + override fun add(bookmarkEntries: Array, onAdded: () -> Unit) { + GlobalScope.launch(Dispatchers.IO){ + val entities = bookmarkEntries.map { entry -> + BookmarkEntity( + label = entry.label, + uri = entry.uri.toString(), + uiIndex = entry.index, + folder = "~/") + } + db.bookmarks().insertAll(entities.toTypedArray()) + onAdded() + } + } + + override fun moveUp(bookmarkEntry: BookmarkEntry, onMoved: () -> Unit) { + GlobalScope.launch(Dispatchers.IO){ + + //todo - this method is broken, + //is it? + val prev = db.bookmarks().getBookmark(bookmarkEntry.index -1) + val target = db.bookmarks().getBookmark(bookmarkEntry.index) + + db.bookmarks().updateUIIndex(prev.uid, bookmarkEntry.index) + db.bookmarks().updateUIIndex(target.uid, bookmarkEntry.index - 1) + onMoved() + } + } + + override fun moveDown(bookmarkEntry: BookmarkEntry, onMoved: () -> Unit) { + GlobalScope.launch(Dispatchers.IO){ + val next = db.bookmarks().getBookmark(bookmarkEntry.index + 1) + val target = db.bookmarks().getBookmark(bookmarkEntry.index) + + db.bookmarks().updateUIIndex(next.uid, bookmarkEntry.index) + db.bookmarks().updateUIIndex(target.uid, bookmarkEntry.index + 1) + onMoved() + } + } + + override fun update(bookmarkEntry: BookmarkEntry, label: String?, uri: String?, onUpdate: () -> Unit) { + GlobalScope.launch(Dispatchers.IO){ + db.bookmarks().updateContent(bookmarkEntry.uid, label ?: "", uri ?: "") + onUpdate() + } + } + + override fun delete(bookmarkEntry: BookmarkEntry, onDelete: () -> Unit) { + GlobalScope.launch(Dispatchers.IO){ + val entity = db.bookmarks().getBookmark(bookmarkEntry.index) + db.bookmarks().delete(entity) + onDelete() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/corewala/buran/io/database/history/BuranHistory.kt b/app/src/main/java/corewala/buran/io/database/history/BuranHistory.kt new file mode 100644 index 0000000..35e9c29 --- /dev/null +++ b/app/src/main/java/corewala/buran/io/database/history/BuranHistory.kt @@ -0,0 +1,77 @@ +package corewala.buran.io.database.history + +import android.net.Uri +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import corewala.buran.io.database.BuranAbstractDatabase + +class BuranHistory(private val db: BuranAbstractDatabase): HistoryDatasource { + + override fun get(onHistory: (List) -> Unit) { + GlobalScope.launch(Dispatchers.IO){ + val dbBookmarks = db.history().getAll() + val history = mutableListOf() + + dbBookmarks.forEach { entity -> + history.add(HistoryEntry(entity.uid, entity.timestamp ?: 0L, Uri.parse(entity.uri))) + } + onHistory(history) + } + } + + override fun add(entry: HistoryEntry, onAdded: () -> Unit) { + GlobalScope.launch(Dispatchers.IO){ + val lastAdded = db.history().getLastAdded() + val entity = HistoryEntity(entry.uri.toString(), System.currentTimeMillis()) + + when (lastAdded) { + null -> db.history().insert(entity) + else -> { + when { + lastAdded.uri.toString() != entry.uri.toString() -> db.history().insert(entity) + } + } + } + + onAdded() + } + } + + override fun add(uri: Uri, onAdded: () -> Unit) { + if(!uri.toString().startsWith("gemini://")){ + onAdded + return + } + GlobalScope.launch(Dispatchers.IO){ + val lastAdded = db.history().getLastAdded() + val entity = HistoryEntity(uri.toString(), System.currentTimeMillis()) + + when (lastAdded) { + null -> db.history().insert(entity) + else -> { + when { + lastAdded.uri.toString() != uri.toString() -> db.history().insert(entity) + } + } + } + + onAdded() + } + } + + override fun clear(onClear: () -> Unit) { + GlobalScope.launch(Dispatchers.IO) { + db.history().clear() + onClear() + } + } + + override fun delete(entry: HistoryEntry, onDelete: () -> Unit) { + GlobalScope.launch(Dispatchers.IO){ + val entity = db.history().getEntry(entry.uid) + db.history().delete(entity) + onDelete() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/corewala/buran/io/database/history/HistoryDao.kt b/app/src/main/java/corewala/buran/io/database/history/HistoryDao.kt new file mode 100644 index 0000000..d9df402 --- /dev/null +++ b/app/src/main/java/corewala/buran/io/database/history/HistoryDao.kt @@ -0,0 +1,27 @@ +package corewala.buran.io.database.history + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.Query + +@Dao +interface HistoryDao { + @Query("SELECT * FROM history ORDER BY timestamp DESC") + suspend fun getAll(): List + + @Query("SELECT * FROM history WHERE uid = :uid LIMIT 1") + fun getEntry(uid: Int): HistoryEntity + + @Query("SELECT * FROM history ORDER BY timestamp DESC LIMIT 1") + fun getLastAdded(): HistoryEntity? + + @Insert + fun insert(vararg history: HistoryEntity) + + @Delete + fun delete(history: HistoryEntity) + + @Query("DELETE FROM history") + fun clear() +} \ No newline at end of file diff --git a/app/src/main/java/corewala/buran/io/database/history/HistoryDatasource.kt b/app/src/main/java/corewala/buran/io/database/history/HistoryDatasource.kt new file mode 100644 index 0000000..63a39e9 --- /dev/null +++ b/app/src/main/java/corewala/buran/io/database/history/HistoryDatasource.kt @@ -0,0 +1,12 @@ +package corewala.buran.io.database.history + +import android.net.Uri + +interface HistoryDatasource { + + fun get(onHistory: (List) -> Unit) + fun add(entry: HistoryEntry, onAdded: () -> Unit) + fun add(uri: Uri, onAdded: () -> Unit) + fun clear(onClear: () -> Unit) + fun delete(entry: HistoryEntry, onDelete: () -> Unit) +} \ No newline at end of file diff --git a/app/src/main/java/corewala/buran/io/database/history/HistoryEntity.kt b/app/src/main/java/corewala/buran/io/database/history/HistoryEntity.kt new file mode 100644 index 0000000..4ac075e --- /dev/null +++ b/app/src/main/java/corewala/buran/io/database/history/HistoryEntity.kt @@ -0,0 +1,14 @@ +package corewala.buran.io.database.history + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "history") +class HistoryEntity( + @ColumnInfo(name = "uri") val uri: String?, + @ColumnInfo(name = "timestamp") val timestamp: Long? +){ + @PrimaryKey(autoGenerate = true) + var uid: Int = 0 +} \ No newline at end of file diff --git a/app/src/main/java/corewala/buran/io/database/history/HistoryEntry.kt b/app/src/main/java/corewala/buran/io/database/history/HistoryEntry.kt new file mode 100644 index 0000000..d00112d --- /dev/null +++ b/app/src/main/java/corewala/buran/io/database/history/HistoryEntry.kt @@ -0,0 +1,9 @@ +package corewala.buran.io.database.history + +import android.net.Uri + +class HistoryEntry( + val uid: Int, + val timestamp: Long, + val uri: Uri +) \ No newline at end of file diff --git a/app/src/main/java/corewala/buran/io/gemini/Datasource.kt b/app/src/main/java/corewala/buran/io/gemini/Datasource.kt new file mode 100644 index 0000000..f07a26d --- /dev/null +++ b/app/src/main/java/corewala/buran/io/gemini/Datasource.kt @@ -0,0 +1,19 @@ +package corewala.buran.io.gemini + +import android.content.Context +import corewala.buran.io.GemState +import corewala.buran.io.database.history.BuranHistory +import java.net.URI + +interface Datasource { + fun request(address: String, onUpdate: (state: GemState) -> Unit) + fun request(address: String, forceDownload: Boolean, onUpdate: (state: GemState) -> Unit) + fun canGoBack(): Boolean + fun goBack(onUpdate: (state: GemState) -> Unit) + + companion object{ + fun factory(context: Context, history: BuranHistory): Datasource { + return GeminiDatasource(context, history) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/corewala/buran/io/gemini/DummyTrustManager.kt b/app/src/main/java/corewala/buran/io/gemini/DummyTrustManager.kt new file mode 100644 index 0000000..abb4767 --- /dev/null +++ b/app/src/main/java/corewala/buran/io/gemini/DummyTrustManager.kt @@ -0,0 +1,37 @@ +package corewala.buran.io.gemini + +import java.security.cert.X509Certificate +import javax.net.ssl.TrustManager +import javax.net.ssl.X509TrustManager +import javax.security.cert.CertificateException +import kotlin.jvm.Throws + +object DummyTrustManager { + + fun get(): Array { + return arrayOf( + object : X509TrustManager { + override fun checkClientTrusted( + chain: Array?, + authType: String? + ) { + + } + + override fun checkServerTrusted( + chain: Array?, + authType: String? + ) { + println("checkServerTrusted()") + println("checkServerTrusted() authType: $authType") + chain?.forEach { cert -> + println("checkServerTrusted() cert: ${cert.subjectDN}") + } + } + + override fun getAcceptedIssuers(): Array { + return arrayOf() + } + }) + } +} \ No newline at end of file diff --git a/app/src/main/java/corewala/buran/io/gemini/GeminiDatasource.kt b/app/src/main/java/corewala/buran/io/gemini/GeminiDatasource.kt new file mode 100644 index 0000000..187e2a5 --- /dev/null +++ b/app/src/main/java/corewala/buran/io/gemini/GeminiDatasource.kt @@ -0,0 +1,245 @@ +package corewala.buran.io.gemini + +import android.content.Context +import androidx.core.net.toUri +import androidx.preference.PreferenceManager +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import corewala.buran.Buran +import corewala.buran.io.GemState +import corewala.buran.io.database.history.BuranHistory +import corewala.buran.io.keymanager.BuranKeyManager +import corewala.toUri +import java.io.* +import java.lang.IllegalStateException +import java.net.ConnectException +import java.net.URI +import java.net.UnknownHostException +import javax.net.ssl.* + +const val GEMINI_SCHEME = "gemini" + +class GeminiDatasource(private val context: Context, val history: BuranHistory): Datasource { + + private val prefs = PreferenceManager.getDefaultSharedPreferences(context) + private val runtimeHistory = mutableListOf() + private var forceDownload = false + + private var onUpdate: (state: GemState) -> Unit = {_ ->} + + private val buranKeyManager = BuranKeyManager(context){ keyError -> + onUpdate(GemState.ClientCertError(GeminiResponse.Header(-3, keyError))) + } + + private var socketFactory: SSLSocketFactory? = null + + override fun request(address: String, forceDownload: Boolean, onUpdate: (state: GemState) -> Unit) { + this.forceDownload = forceDownload + request(address, onUpdate) + } + + override fun request(address: String, onUpdate: (state: GemState) -> Unit) { + this.onUpdate = onUpdate + + val uri = URI.create(address) + + onUpdate(GemState.Requesting(uri)) + + GlobalScope.launch { + geminiRequest(uri, onUpdate) + } + } + + private fun initSSLFactory(protocol: String){ + val sslContext = when (protocol) { + "TLS_ALL" -> SSLContext.getInstance("TLS") + else -> SSLContext.getInstance(protocol) + } + + sslContext.init(buranKeyManager.getFactory()?.keyManagers, DummyTrustManager.get(), null) + socketFactory = sslContext.socketFactory + } + + private fun geminiRequest(uri: URI, onUpdate: (state: GemState) -> Unit){ + val protocol = prefs.getString("tls_protocol", "TLS") + val useClientCert = prefs.getBoolean(Buran.PREF_KEY_CLIENT_CERT_ACTIVE, false) + + //Update factory if operating mode has changed + when { + socketFactory == null -> initSSLFactory(protocol!!) + useClientCert && !buranKeyManager.lastCallUsedKey -> initSSLFactory(protocol!!) + !useClientCert && buranKeyManager.lastCallUsedKey -> initSSLFactory(protocol!!) + } + + println("REQ_PROTOCOL: $protocol") + + val socket: SSLSocket? + try { + socket = socketFactory?.createSocket(uri.host, 1965) as SSLSocket + + when (protocol) { + "TLS" -> { + }//Use default enabled protocols + "TLS_ALL" -> socket.enabledProtocols = socket.supportedProtocols + else -> socket.enabledProtocols = arrayOf(protocol) + } + + println("Buran socket handshake with ${uri.host}") + socket.startHandshake() + }catch (uhe: UnknownHostException){ + println("Buran socket error, unknown host: $uhe") + onUpdate(GemState.ResponseUnknownHost(uri)) + return + }catch (ce: ConnectException){ + println("Buran socket error, connect exception: $ce") + onUpdate(GemState.ResponseError(GeminiResponse.Header(-1, ce.message ?: ce.toString()))) + return + }catch (she: SSLHandshakeException){ + println("Buran socket error, ssl handshake exception: $she") + onUpdate(GemState.ResponseError(GeminiResponse.Header(-2, she.message ?: she.toString()))) + return + } + + // OUT >>>>>>>>>>>>>>>>>>>>>>>>>> + val outputStreamWriter = OutputStreamWriter(socket.outputStream) + val bufferedWriter = BufferedWriter(outputStreamWriter) + val outWriter = PrintWriter(bufferedWriter) + + val requestEntity = uri.toString() + "\r\n" + println("Buran socket requesting $requestEntity") + outWriter.print(requestEntity) + outWriter.flush() + + if (outWriter.checkError()) { + onUpdate(GemState.ResponseError(GeminiResponse.Header(-1, "Print Writer Error"))) + outWriter.close() + return + } + + // IN <<<<<<<<<<<<<<<<<<<<<<<<<<< + + val inputStream = socket.inputStream + val headerInputReader = InputStreamReader(inputStream) + val bufferedReader = BufferedReader(headerInputReader) + val headerLine = bufferedReader.readLine() + + println("Buran: response header: $headerLine") + + if(headerLine == null){ + onUpdate(GemState.ResponseError(GeminiResponse.Header(-2, "Server did not respond with a Gemini header: $uri"))) + return + } + + val header = GeminiResponse.parseHeader(headerLine) + + when { + header.code == GeminiResponse.INPUT -> onUpdate(GemState.ResponseInput(uri, header)) + header.code == GeminiResponse.REDIRECT -> request(URI.create(header.meta).toString(), onUpdate) + header.code != GeminiResponse.SUCCESS -> onUpdate(GemState.ResponseError(header)) + header.meta.startsWith("text/gemini") -> getGemtext(bufferedReader, uri, header, onUpdate) + header.meta.startsWith("text/") -> getString(socket, uri, header, onUpdate) + header.meta.startsWith("image/") -> getBinary(socket, uri, header, onUpdate) + else -> { + //File served over Gemini but not handled in-app, eg .pdf + if(forceDownload){ + getBinary(socket, uri, header, onUpdate) + }else{ + onUpdate(GemState.ResponseUnknownMime(uri, header)) + } + } + } + + //Close input + bufferedReader.close() + headerInputReader.close() + + //Close output: + outputStreamWriter.close() + bufferedWriter.close() + outWriter.close() + + socket.close() + } + + private fun getGemtext(reader: BufferedReader, uri: URI, header: GeminiResponse.Header, onUpdate: (state: GemState) -> Unit){ + + val lines = mutableListOf() + + lines.addAll(reader.readLines()) + + val processed = GemtextHelper.findCodeBlocks(lines) + + when { + !uri.toString().startsWith("gemini://") -> throw IllegalStateException("Not a Gemini Uri") + } + + updateHistory(uri) + onUpdate(GemState.ResponseGemtext(uri, header, processed)) + } + + private fun updateHistory(uri: URI) { + if (runtimeHistory.isEmpty() || runtimeHistory.last().toString() != uri.toString()) { + runtimeHistory.add(uri) + println("Buran added $uri to runtime history (size ${runtimeHistory.size})") + } + + history.add(uri.toUri()){} + } + + private fun getString(socket: SSLSocket?, uri: URI, header: GeminiResponse.Header, onUpdate: (state: GemState) -> Unit){ + val content = socket?.inputStream?.bufferedReader().use { + reader -> reader?.readText() + } + socket?.close() + onUpdate(GemState.ResponseText(uri, header, content ?: "Error fetching content")) + } + + private fun getBinary(socket: SSLSocket?, uri: URI, header: GeminiResponse.Header, onUpdate: (state: GemState) -> Unit){ + + var filename: String? = null + val fileSegmentIndex: Int = uri.path.lastIndexOf('/') + + when { + fileSegmentIndex != -1 -> filename = uri.path.substring(fileSegmentIndex + 1) + } + + val host = uri.host.replace(".", "_") + val cacheName = "${host}_$filename" + println("Caching file: $filename from uri: $uri, cacheName: $cacheName") + + val cacheFile = File(context.cacheDir, cacheName) + + when { + cacheFile.exists() -> { + when { + header.meta.startsWith("image/") -> onUpdate(GemState.ResponseImage(uri, header, cacheFile.toUri())) + else -> onUpdate(GemState.ResponseBinary(uri, header, cacheFile.toUri())) + } + } + else -> { + cacheFile.createNewFile() + cacheFile.outputStream().use{ outputStream -> + socket?.inputStream?.copyTo(outputStream) + socket?.close() + } + + when { + header.meta.startsWith("image/") -> onUpdate(GemState.ResponseImage(uri, header, cacheFile.toUri())) + else -> onUpdate(GemState.ResponseBinary(uri, header, cacheFile.toUri())) + } + } + } + } + + override fun canGoBack(): Boolean = runtimeHistory.isEmpty() || runtimeHistory.size > 1 + + override fun goBack(onUpdate: (state: GemState) -> Unit) { + runtimeHistory.removeLast() + request(runtimeHistory.last().toString(), onUpdate) + } + + //This just forces the factory to rebuild before the next request + fun invalidate() { + socketFactory = null + } +} \ No newline at end of file diff --git a/app/src/main/java/corewala/buran/io/gemini/GeminiResponse.kt b/app/src/main/java/corewala/buran/io/gemini/GeminiResponse.kt new file mode 100644 index 0000000..d67e433 --- /dev/null +++ b/app/src/main/java/corewala/buran/io/gemini/GeminiResponse.kt @@ -0,0 +1,81 @@ +package corewala.buran.io.gemini + +object GeminiResponse { + + const val INPUT = 1 + const val SUCCESS = 2 + const val REDIRECT = 3 + const val TEMPORARY_FAILURE = 4 + const val PERMANENT_FAILURE = 5 + const val CLIENT_CERTIFICATE_REQUIRED = 6 + const val UNKNOWN = -1 + + fun parseHeader(header: String): Header { + val cleanHeader = header.replace("\\s+".toRegex(), " ") + val meta: String + when { + header.startsWith("2") -> { + val segments = cleanHeader.trim().split(" ") + meta = when { + segments.size > 1 -> segments[1] + else -> "text/gemini; charset=utf-8" + } + } + else -> { + + meta = when { + cleanHeader.contains(" ") -> cleanHeader.substring(cleanHeader.indexOf(" ") + 1) + else -> cleanHeader + } + } + } + + return when { + header.startsWith("1") -> Header( + INPUT, + meta + ) + header.startsWith("2") -> Header( + SUCCESS, + meta + ) + header.startsWith("3") -> Header( + REDIRECT, + meta + ) + header.startsWith("4") -> Header( + TEMPORARY_FAILURE, + meta + ) + header.startsWith("5") -> Header( + PERMANENT_FAILURE, + meta + ) + header.startsWith("6") -> Header( + CLIENT_CERTIFICATE_REQUIRED, + meta + ) + else -> Header( + UNKNOWN, + meta + ) + } + } + + fun getCodeString(code: Int): String{ + return when(code){ + 1 -> "Input" + 2 -> "Success" + 3 -> "Redirect" + 4 -> "Temporary Failure" + 5 -> "Permanent Failure" + 6 -> "Client Certificate Required" + -3 -> "Client Certificate Error" + -2 -> "Bad response: Server Error" + -1 -> "Connection Error" + else -> "Unknown: $code" + } + } + + class Header(val code: Int, val meta: String) +} \ No newline at end of file diff --git a/app/src/main/java/corewala/buran/io/gemini/GemtextHelper.kt b/app/src/main/java/corewala/buran/io/gemini/GemtextHelper.kt new file mode 100644 index 0000000..a43e867 --- /dev/null +++ b/app/src/main/java/corewala/buran/io/gemini/GemtextHelper.kt @@ -0,0 +1,44 @@ +package corewala.buran.io.gemini + +import java.lang.StringBuilder + +object GemtextHelper { + + /** + * + * This is safe for most cases but fails when a line starts with ``` _within_ a code block + * + */ + fun findCodeBlocks(source: List): List{ + val sb = StringBuilder() + var inCodeBlock = false + val parsed = mutableListOf() + source.forEach { line -> + if (line.startsWith("```")) { + if (!inCodeBlock) { + //New code block starting + sb.clear() + sb.append("```") + + if(line.length > 3){ + //Code block has alt text + val alt = line.substring(3) + sb.append("<|ALT|>$alt") + } + } else { + //End of code block + parsed.add(sb.toString()) + } + inCodeBlock = !inCodeBlock + } else { + if (inCodeBlock) { + sb.append("$line\n") + } else { + parsed.add(line) + } + } + } + + return parsed + } +} \ No newline at end of file diff --git a/app/src/main/java/corewala/buran/io/history/uris/BasicURIHistory.kt b/app/src/main/java/corewala/buran/io/history/uris/BasicURIHistory.kt new file mode 100644 index 0000000..d1c7620 --- /dev/null +++ b/app/src/main/java/corewala/buran/io/history/uris/BasicURIHistory.kt @@ -0,0 +1,44 @@ +package corewala.buran.io.history.uris + +import android.content.Context + +/** + * + * Another shared prefs implementation so I don't get slowed down by a Room implementation at this point + * + */ +class BasicURIHistory(context: Context): HistoryInterface { + + private val DELIM = "||" + private val prefsKey = "history.BasicURIHistory.prefsKey" + private val prefsHistoryKey = "history.BasicURIHistory.prefsHistoryKey" + private val prefs = context.getSharedPreferences(prefsKey, Context.MODE_PRIVATE) + + override fun add(address: String) { + + val history = get() + + when { + history.size >= 50 -> history.removeAt(0) + } + + if(history.isNotEmpty() && history.size > 10){ + if(history.subList(history.size - 10, history.size).contains(address)) return + } + + history.add(address) + val raw = history.joinToString(DELIM) + prefs.edit().putString(prefsHistoryKey, raw).apply() + } + + override fun clear(){ + prefs.edit().clear().apply() + } + + override fun get(): ArrayList { + return when (val raw = prefs.getString(prefsHistoryKey, null)) { + null -> arrayListOf() + else -> ArrayList(raw.split(DELIM)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/corewala/buran/io/history/uris/HistoryInterface.kt b/app/src/main/java/corewala/buran/io/history/uris/HistoryInterface.kt new file mode 100644 index 0000000..0a9a8dd --- /dev/null +++ b/app/src/main/java/corewala/buran/io/history/uris/HistoryInterface.kt @@ -0,0 +1,15 @@ +package corewala.buran.io.history.uris + +import android.content.Context + +interface HistoryInterface { + fun add(address: String) + fun get(): List + fun clear() + + companion object{ + fun default(context: Context): HistoryInterface { + return BasicURIHistory(context) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/corewala/buran/io/keymanager/BuranKeyManager.kt b/app/src/main/java/corewala/buran/io/keymanager/BuranKeyManager.kt new file mode 100644 index 0000000..653d14f --- /dev/null +++ b/app/src/main/java/corewala/buran/io/keymanager/BuranKeyManager.kt @@ -0,0 +1,67 @@ +package corewala.buran.io.keymanager + +import android.content.Context +import android.content.SharedPreferences +import android.net.Uri +import androidx.preference.PreferenceManager +import corewala.buran.Buran +import corewala.buran.R +import java.io.FileNotFoundException +import java.io.IOException +import java.security.KeyStore +import javax.net.ssl.KeyManagerFactory + + +class BuranKeyManager(val context: Context, val onKeyError: (error: String) -> Unit) { + + var prefs: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) + + var lastCallUsedKey = false + + //If the user has a key loaded load it here - or else return null + fun getFactory(): KeyManagerFactory? { + val isClientCertActive = prefs.getBoolean(Buran.PREF_KEY_CLIENT_CERT_ACTIVE, false) + return when { + isClientCertActive -> { + lastCallUsedKey = true + val keyStore: KeyStore = KeyStore.getInstance("pkcs12") + + val uriStr = prefs.getString(Buran.PREF_KEY_CLIENT_CERT_URI, "") + val password = prefs.getString(Buran.PREF_KEY_CLIENT_CERT_PASSWORD, "") + val uri = Uri.parse(uriStr) + try { + context.contentResolver?.openInputStream(uri)?.use { + try { + keyStore.load(it, password?.toCharArray()) + val keyManagerFactory: KeyManagerFactory = + KeyManagerFactory.getInstance("X509") + keyManagerFactory.init(keyStore, password?.toCharArray()) + return@use keyManagerFactory + } catch (ioe: IOException) { + onKeyError("${ioe.message}") + return null + } + } + } catch(fnf: FileNotFoundException){ + onKeyError("Please link your client certificate again in Settings; after an update Buran loses permissions to access external files, or the certificate has been moved/deleted\n\n${fnf.message}") + return null + } + } + else -> { + lastCallUsedKey = false + null + } + } + } + + //Working example with cert packaged with app + fun getFactoryDemo(context: Context): KeyManagerFactory? { + val keyStore: KeyStore = KeyStore.getInstance("pkcs12") + keyStore.load(context.resources.openRawResource(R.raw.cert), "PASSWORD".toCharArray()) + + val keyManagerFactory: KeyManagerFactory = KeyManagerFactory.getInstance("X509") + keyManagerFactory.init(keyStore, "PASSWORD".toCharArray()) + + return keyManagerFactory + } +} \ No newline at end of file diff --git a/app/src/main/java/corewala/buran/ui/GemActivity.kt b/app/src/main/java/corewala/buran/ui/GemActivity.kt new file mode 100644 index 0000000..0fe3305 --- /dev/null +++ b/app/src/main/java/corewala/buran/ui/GemActivity.kt @@ -0,0 +1,595 @@ +package corewala.buran.ui + +import android.app.DownloadManager +import android.content.ActivityNotFoundException +import android.content.Intent +import android.content.SharedPreferences +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.net.Uri +import android.os.Bundle +import android.view.WindowManager +import android.view.inputmethod.EditorInfo +import androidx.activity.viewModels +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.browser.customtabs.CustomTabsIntent +import androidx.databinding.DataBindingUtil +import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.snackbar.Snackbar +import corewala.* +import kotlinx.android.synthetic.main.activity_gem.* +import corewala.buran.Buran +import corewala.buran.OmniTerm +import corewala.buran.R +import corewala.buran.databinding.ActivityGemBinding +import corewala.buran.io.GemState +import corewala.buran.io.database.BuranDatabase +import corewala.buran.io.database.bookmarks.BookmarksDatasource +import corewala.buran.io.gemini.Datasource +import corewala.buran.io.gemini.GeminiResponse +import corewala.buran.ui.bookmarks.BookmarkDialog +import corewala.buran.ui.bookmarks.BookmarksDialog +import corewala.buran.ui.content_image.ImageDialog +import corewala.buran.ui.content_text.TextDialog +import corewala.buran.ui.gemtext_adapters.* +import corewala.buran.ui.modals_menus.about.AboutDialog +import corewala.buran.ui.modals_menus.history.HistoryDialog +import corewala.buran.ui.modals_menus.input.InputDialog +import corewala.buran.ui.modals_menus.overflow.OverflowPopup +import corewala.buran.ui.settings.SettingsActivity +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.net.URI + +const val CREATE_IMAGE_FILE_REQ = 628 +const val CREATE_BINARY_FILE_REQ = 630 +const val CREATE_BOOKMARK_EXPORT_FILE_REQ = 631 +const val CREATE_BOOKMARK_IMPORT_FILE_REQ = 632 + +class GemActivity : AppCompatActivity() { + + lateinit var prefs: SharedPreferences + private var inSearch = false + private lateinit var bookmarkDatasource: BookmarksDatasource + private var bookmarksDialog: BookmarksDialog? = null + + private val model by viewModels() + private lateinit var binding: ActivityGemBinding + + private val omniTerm = OmniTerm(object : OmniTerm.Listener { + override fun request(address: String) { + loadingView(true) + model.request(address) + } + + override fun openBrowser(address: String) = openWebLink(address) + }) + + lateinit var adapter: AbstractGemtextAdapter + + private val onLink: (link: URI, longTap: Boolean, adapterPosition: Int) -> Unit = { uri, longTap, position: Int -> + if(longTap){ + loadingView(true) + + omniTerm.imageAddress(uri.toString()) + omniTerm.uri.let{ + model.requestInlineImage(URI.create(it.toString())){ imageUri -> + imageUri?.let{ + runOnUiThread { + loadingView(false) + loadImage(position, imageUri) + } + } + } + } + + }else{ + //Reset input text hint after user has been searching + if(inSearch) { + binding.addressEdit.hint = getString(R.string.main_input_hint) + inSearch = false + } + + omniTerm.navigation(uri.toString()) + } + } + + private fun loadImage(position: Int, uri: Uri) { + adapter.loadImage(position, uri) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val db = BuranDatabase(applicationContext) + bookmarkDatasource = db.bookmarks() + + binding = DataBindingUtil.setContentView(this, R.layout.activity_gem) + binding.viewmodel = model + binding.lifecycleOwner = this + + window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN) + + binding.gemtextRecycler.layoutManager = LinearLayoutManager(this) + + prefs = PreferenceManager.getDefaultSharedPreferences(this) + + adapter = when { + prefs.getBoolean("use_large_gemtext_adapter", false) -> AbstractGemtextAdapter.getLargeGmi(onLink) + else -> AbstractGemtextAdapter.getDefault(onLink) + } + + binding.gemtextRecycler.adapter = adapter + + model.initialise( + home = prefs.getString( + "home_capsule", + Buran.DEFAULT_HOME_CAPSULE + ) ?: Buran.DEFAULT_HOME_CAPSULE, + gemini = Datasource.factory(this, db.history()), + db = db, + onState = this::handleState + ) + + binding.addressEdit.setOnEditorActionListener { _, actionId, _ -> + when (actionId) { + EditorInfo.IME_ACTION_GO -> { + omniTerm.input(binding.addressEdit.text.toString().trim()) + binding.addressEdit.hideKeyboard() + binding.addressEdit.clearFocus() + return@setOnEditorActionListener true + } + else -> return@setOnEditorActionListener false + } + } + + binding.addressEdit.setOnFocusChangeListener { v, hasFocus -> + + var addressPaddingRight = resources.getDimensionPixelSize(R.dimen.def_address_right_margin) + + if(hasFocus) { + binding.addressEdit.showKeyboard() + focusEnd() + }else{ + binding.addressEdit.hideKeyboard() + } + + binding.addressEdit.setPadding( + binding.addressEdit.paddingLeft, + binding.addressEdit.paddingTop, + addressPaddingRight, + binding.addressEdit.paddingBottom, + ) + } + + binding.more.setOnClickListener { + OverflowPopup.show(binding.more){ menuId -> + when (menuId) { + R.id.overflow_menu_search -> { + binding.addressEdit.hint = getString(R.string.main_input_search_hint) + binding.addressEdit.text?.clear() + binding.addressEdit.requestFocus() + inSearch = true + } + R.id.overflow_menu_bookmark -> { + val name = adapter.inferTitle() + BookmarkDialog( + this, + BookmarkDialog.mode_new, + bookmarkDatasource, + binding.addressEdit.text.toString(), + name ?: "" + ) { _, _ -> + }.show() + } + R.id.overflow_menu_bookmarks -> { + bookmarksDialog = BookmarksDialog(this, bookmarkDatasource) { bookmark -> + model.request(bookmark.uri.toString()) + } + bookmarksDialog?.show() + } + R.id.overflow_menu_share -> { + Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, binding.addressEdit.text.toString()) + type = "text/plain" + startActivity(Intent.createChooser(this, null)) + } + } + R.id.overflow_menu_history -> HistoryDialog.show( + this, + db.history() + ) { historyAddress -> + model.request(historyAddress) + } + R.id.overflow_menu_about -> AboutDialog.show(this) + R.id.overflow_menu_settings -> { + startActivity(Intent(this, SettingsActivity::class.java)) + } + } + } + } + + binding.home.setOnClickListener { + val home = PreferenceManager.getDefaultSharedPreferences(this).getString( + "home_capsule", + Buran.DEFAULT_HOME_CAPSULE + ) + omniTerm.history.clear() + model.request(home!!) + } + + binding.pullToRefresh.setOnRefreshListener { + refresh() + } + + checkIntentExtras(intent) + } + + private fun refresh(){ + omniTerm.getCurrent().run{ + binding.addressEdit.setText(this) + focusEnd() + model.request(this) + } + } + + override fun onResume() { + super.onResume() + + when { + prefs.contains("background_colour") -> { + when (val backgroundColor = prefs.getString("background_colour", "#XXXXXX")) { + "#XXXXXX" -> binding.rootCoord.background = null + else -> binding.rootCoord.background = ColorDrawable(Color.parseColor("$backgroundColor")) + } + } + } + + when { + prefs.getBoolean( + Buran.PREF_KEY_CLIENT_CERT_ACTIVE, + false + ) -> { + binding.addressEdit.setCompoundDrawablesWithIntrinsicBounds( + R.drawable.vector_client_cert, + 0, + 0, + 0 + ) + binding.addressEdit.compoundDrawablePadding = 6.toPx().toInt() + } + else -> hideClientCertShield() + } + + val useLargeGmiAdapter = prefs.getBoolean("use_large_gemtext_adapter", false) + when { + useLargeGmiAdapter -> { + if(adapter.typeId != GEMTEXT_ADAPTER_LARGE){ + gemtext_recycler.adapter = null + adapter = AbstractGemtextAdapter.getLargeGmi(onLink) + gemtext_recycler.adapter = adapter + refresh() + } + } + else -> { + if(adapter.typeId != GEMTEXT_ADAPTER_DEFAULT) { + gemtext_recycler.adapter = null + adapter = AbstractGemtextAdapter.getDefault(onLink) + gemtext_recycler.adapter = adapter + refresh() + } + } + } + + val hideCodeBlocks = prefs.getBoolean( + "collapse_code_blocks", + false + ) + adapter.hideCodeBlocks(hideCodeBlocks) + + val showInlineIcons = prefs.getBoolean( + "show_inline_icons", + true + ) + adapter.inlineIcons(showInlineIcons) + + + model.invalidateDatasource() + } + + private fun hideClientCertShield(){ + binding.addressEdit.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0) + } + + private fun handleState(state: GemState) { + binding.pullToRefresh.isRefreshing = false + + when (state) { + is GemState.AppQuery -> runOnUiThread { showAlert("App backdoor/query not implemented yet") } + is GemState.ResponseInput -> runOnUiThread { + loadingView(false) + InputDialog.show(this, state) { queryAddress -> + model.request(queryAddress) + } + } + is GemState.Requesting -> loadingView(true) + is GemState.NotGeminiRequest -> externalProtocol(state) + is GemState.ResponseError -> { + omniTerm.reset() + showAlert("${GeminiResponse.getCodeString(state.header.code)}:\n\n${state.header.meta}") + } + is GemState.ClientCertError -> { + hideClientCertShield() + showAlert("${GeminiResponse.getCodeString(state.header.code)}:\n\n${state.header.meta}") + } + is GemState.ResponseGemtext -> renderGemtext(state) + is GemState.ResponseText -> renderText(state) + is GemState.ResponseImage -> renderImage(state) + is GemState.ResponseBinary -> renderBinary(state) + is GemState.Blank -> { + binding.addressEdit.setText("") + adapter.render(arrayListOf()) + } + is GemState.ResponseUnknownMime -> { + runOnUiThread { + loadingView(false) + + val download = getString(R.string.download) + + AlertDialog.Builder(this, R.style.AppDialogTheme) + .setTitle("$download: ${state.header.meta}") + .setMessage("${state.uri}") + .setPositiveButton(getString(R.string.download)) { _, _ -> + loadingView(true) + model.requestBinaryDownload(state.uri) + } + .setNegativeButton(getString(R.string.cancel)) { _, _ -> } + .show() + } + } + is GemState.ResponseUnknownHost -> { + runOnUiThread { + loadingView(false) + AlertDialog.Builder(this, R.style.AppDialogTheme) + .setTitle(R.string.unknown_host_dialog_title) + .setMessage("Host not found: ${state.uri}\n\nSearch with GUS instead?") + .setPositiveButton(getString(R.string.search)) { _, _ -> + loadingView(true) + omniTerm.search(state.uri.toString()) + } + .setNegativeButton(getString(R.string.cancel)) { _, _ -> } + .show() + } + } + } + } + + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + + intent?.let{ + checkIntentExtras(intent) + } + } + + /** + * + * Checks intent to see if Activity was opened to handle selected text + * + */ + private fun checkIntentExtras(intent: Intent) { + + //Via ProcessTextActivity from selected text in another app + if(intent.hasExtra("process_text")){ + val processText = intent.getStringExtra("process_text") + binding.addressEdit.setText(processText) + model.request(processText ?: "") + return + } + + //From clicking a gemini:// address + val uri = intent.data + if(uri != null){ + binding.addressEdit.setText(uri.toString()) + model.request(uri.toString()) + return + } + } + + private fun showAlert(message: String) = runOnUiThread{ + loadingView(false) + + if(message.length > 40){ + AlertDialog.Builder(this) + .setTitle(getString(R.string.error)) + .setMessage(message) + .setPositiveButton("OK"){ _, _ -> + + } + .show() + }else { + Snackbar.make(binding.root, message, Snackbar.LENGTH_SHORT).show() + } + } + + private fun externalProtocol(state: GemState.NotGeminiRequest) = runOnUiThread { + loadingView(false) + val uri = state.uri.toString() + + when { + (uri.startsWith("http://") || uri.startsWith("https://")) -> openWebLink(uri) + else -> { + val viewIntent = Intent(Intent.ACTION_VIEW) + viewIntent.data = Uri.parse(state.uri.toString()) + + try { + startActivity(viewIntent) + }catch (e: ActivityNotFoundException){ + showAlert( + String.format( + getString(R.string.not_app_installed_that_can_open), + state.uri + ) + ) + } + } + } + } + + private fun openWebLink(address: String){ + if(PreferenceManager.getDefaultSharedPreferences(this).getBoolean( + Buran.PREF_KEY_USE_CUSTOM_TAB, + true + )) { + val builder = CustomTabsIntent.Builder() + val intent = builder.build() + intent.launchUrl(this, Uri.parse(address)) + }else{ + val viewIntent = Intent(Intent.ACTION_VIEW) + viewIntent.data = Uri.parse(address) + startActivity(viewIntent) + } + } + + private fun renderGemtext(state: GemState.ResponseGemtext) = runOnUiThread { + loadingView(false) + + omniTerm.set(state.uri.toString()) + + //todo - colours didn't change when switching themes, so disabled for now + //val addressSpan = SpannableString(state.uri.toString()) + //addressSpan.set(0, 9, ForegroundColorSpan(resources.getColor(R.color.protocol_address))) + binding.addressEdit.setText(state.uri.toString()) + + adapter.render(state.lines) + + //Scroll to top + binding.gemtextRecycler.post { + binding.gemtextRecycler.scrollToPosition(0) + } + + focusEnd() + } + + private fun renderText(state: GemState.ResponseText) = runOnUiThread { + loadingView(false) + TextDialog.show(this, state) + } + + var imageState: GemState.ResponseImage? = null + var binaryState: GemState.ResponseBinary? = null + + private fun renderImage(state: GemState.ResponseImage) = runOnUiThread{ + loadingView(false) + ImageDialog.show(this, state){ state -> + imageState = state + val intent = Intent(Intent.ACTION_CREATE_DOCUMENT) + intent.addCategory(Intent.CATEGORY_OPENABLE) + intent.type = "image/*" + intent.putExtra(Intent.EXTRA_TITLE, File(state.uri.path).name) + startActivityForResult(intent, CREATE_IMAGE_FILE_REQ) + } + } + + private fun renderBinary(state: GemState.ResponseBinary) = runOnUiThread{ + loadingView(false) + binaryState = state + val intent = Intent(Intent.ACTION_CREATE_DOCUMENT) + intent.addCategory(Intent.CATEGORY_OPENABLE) + intent.type = state.header.meta + intent.putExtra(Intent.EXTRA_TITLE, File(state.uri.path).name) + startActivityForResult(intent, CREATE_BINARY_FILE_REQ) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if(resultCode == RESULT_OK && (requestCode == CREATE_IMAGE_FILE_REQ || requestCode == CREATE_BINARY_FILE_REQ)){ + //todo - tidy this mess up... refactor - none of this should be here + if(imageState == null && binaryState == null) return + data?.data?.let{ uri -> + val cachedFile = when { + imageState != null -> File(imageState!!.cacheUri.path ?: "") + binaryState != null -> File(binaryState!!.cacheUri.path ?: "") + else -> { + println("File download error - no state object exists") + showAlert(getString(R.string.no_state_object_exists)) + null + } + } + + cachedFile?.let{ + contentResolver.openFileDescriptor(uri, "w")?.use { fileDescriptor -> + FileOutputStream(fileDescriptor.fileDescriptor).use { destOutput -> + val sourceChannel = FileInputStream(cachedFile).channel + val destChannel = destOutput.channel + sourceChannel.transferTo(0, sourceChannel.size(), destChannel) + sourceChannel.close() + destChannel.close() + + cachedFile.deleteOnExit() + + if(binaryState != null){ + startActivity(Intent(DownloadManager.ACTION_VIEW_DOWNLOADS)) + }else{ + Snackbar.make( + binding.root, + getString(R.string.file_saved_to_device), + Snackbar.LENGTH_SHORT + ).show() + } + } + } + } + } + + imageState = null + binaryState = null + }else if(resultCode == RESULT_OK && requestCode == CREATE_BOOKMARK_EXPORT_FILE_REQ){ + data?.data?.let{ uri -> + bookmarksDialog?.bookmarksExportFileReady(uri) + } + }else if(resultCode == RESULT_OK && requestCode == CREATE_BOOKMARK_IMPORT_FILE_REQ){ + data?.data?.let{ uri -> + bookmarksDialog?.bookmarksImportFileReady(uri) + } + } + } + + private fun loadingView(visible: Boolean) = runOnUiThread { + binding.progressBar.visibleRetainingSpace(visible) + if(visible) binding.appBar.setExpanded(true) + } + + override fun onBackPressed() { + if (omniTerm.canGoBack()){ + model.request(omniTerm.goBack()) + }else{ + println("Buran history is empty - exiting") + super.onBackPressed() + cacheDir.deleteRecursively() + } + } + + private fun focusEnd(){ + binding.addressEdit.setSelection(binding.addressEdit.text?.length ?: 0) + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + val uri = binding.addressEdit.text.toString() + outState.putString("uri", uri) + } + + override fun onRestoreInstanceState(savedInstanceState: Bundle) { + super.onRestoreInstanceState(savedInstanceState) + savedInstanceState.getString("uri")?.run { + omniTerm.set(this) + binding.addressEdit.setText(this) + model.request(this) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/corewala/buran/ui/GemViewModel.kt b/app/src/main/java/corewala/buran/ui/GemViewModel.kt new file mode 100644 index 0000000..2ff95cf --- /dev/null +++ b/app/src/main/java/corewala/buran/ui/GemViewModel.kt @@ -0,0 +1,53 @@ +package corewala.buran.ui + +import android.net.Uri +import androidx.lifecycle.ViewModel +import corewala.buran.io.gemini.Datasource +import corewala.buran.io.GemState +import corewala.buran.io.database.BuranDatabase +import corewala.buran.io.gemini.GeminiDatasource +import java.net.URI + +class GemViewModel: ViewModel() { + + private lateinit var gemini: Datasource + private lateinit var db: BuranDatabase + private var onState: (state: GemState) -> Unit = {} + + fun initialise(home: String, gemini: Datasource, db: BuranDatabase, onState: (state: GemState) -> Unit){ + this.gemini = gemini + this.db = db + this.onState = onState + + request(home) + } + + fun request(address: String) { + gemini.request(address){ state -> + onState(state) + } + } + + fun requestBinaryDownload(uri: URI) { + gemini.request(uri.toString(), true){ state -> + onState(state) + } + } + + //todo - same action as above... refactor + fun requestInlineImage(uri: URI, onImageReady: (cacheUri: Uri?) -> Unit){ + gemini.request(uri.toString()){ state -> + when (state) { + is GemState.ResponseImage -> onImageReady(state.cacheUri) + else -> onState(state) + } + } + } + + //If user changes client cert prefs in Settings this awful hack causes it to refresh state on next request + fun invalidateDatasource() { + if(gemini is GeminiDatasource){ + (gemini as GeminiDatasource).invalidate() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/corewala/buran/ui/ProcessTextActivity.kt b/app/src/main/java/corewala/buran/ui/ProcessTextActivity.kt new file mode 100644 index 0000000..f84f7ff --- /dev/null +++ b/app/src/main/java/corewala/buran/ui/ProcessTextActivity.kt @@ -0,0 +1,24 @@ +package corewala.buran.ui + +import android.content.Intent +import android.os.Build +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity + +class ProcessTextActivity: AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val processText = when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && intent.hasExtra(Intent.EXTRA_PROCESS_TEXT) -> intent.getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT).toString() + else -> null + } + + Intent(this, GemActivity::class.java).run { + putExtra("process_text", processText) + startActivity(this) + finish() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/corewala/buran/ui/bookmarks/BookmarkDialog.kt b/app/src/main/java/corewala/buran/ui/bookmarks/BookmarkDialog.kt new file mode 100644 index 0000000..391c20c --- /dev/null +++ b/app/src/main/java/corewala/buran/ui/bookmarks/BookmarkDialog.kt @@ -0,0 +1,82 @@ +package corewala.buran.ui.bookmarks + +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.view.View +import androidx.appcompat.app.AppCompatDialog +import kotlinx.android.synthetic.main.fragment_bookmark_dialog.view.* +import corewala.buran.R +import corewala.buran.io.database.bookmarks.BookmarkEntry +import corewala.buran.io.database.bookmarks.BookmarksDatasource +import java.net.URI + +class BookmarkDialog( + context: Context, + private val mode: Int, + private val bookmarkDatasource: BookmarksDatasource?, + val uri: String, + val name: String, + onDismiss: (label: String?, uri: String?) -> Unit) : AppCompatDialog(context, R.style.FSDialog) { + + companion object{ + const val mode_new = 0 + const val mode_edit = 1 + } + + init { + val view = View.inflate(context, R.layout.fragment_bookmark_dialog, null) + + setContentView(view) + + view.bookmark_toolbar.setNavigationIcon(R.drawable.vector_close) + view.bookmark_toolbar.setNavigationOnClickListener { + onDismiss(null, null) + dismiss() + } + + view.bookmark_name.setText(name) + view.bookmark_uri.setText(uri) + + view.bookmark_toolbar.inflateMenu(R.menu.add_bookmark) + view.bookmark_toolbar.setOnMenuItemClickListener {menuItem -> + if(menuItem.itemId == R.id.menu_action_save_bookmark){ + + if(mode == mode_new) { + //Determine index: + //todo - this is expensive, just get last item, limit1? + bookmarkDatasource?.get { allBookmarks -> + + val index = when { + allBookmarks.isEmpty() -> 0 + else -> allBookmarks.last().index + 1 + } + + bookmarkDatasource.add( + + BookmarkEntry( + uid = -1, + label = view.bookmark_name.text.toString(), + uri = URI.create(view.bookmark_uri.text.toString()), + index = index + ) + ) { + Handler(Looper.getMainLooper()).post { + onDismiss(null, null) + dismiss() + } + } + } + }else if(mode == mode_edit){ + onDismiss( + view.bookmark_name.text.toString(), + view.bookmark_uri.text.toString()) + dismiss() + } + } + + true + + } + } +} \ No newline at end of file diff --git a/app/src/main/java/corewala/buran/ui/bookmarks/BookmarksAdapter.kt b/app/src/main/java/corewala/buran/ui/bookmarks/BookmarksAdapter.kt new file mode 100644 index 0000000..35c4fbd --- /dev/null +++ b/app/src/main/java/corewala/buran/ui/bookmarks/BookmarksAdapter.kt @@ -0,0 +1,69 @@ +package corewala.buran.ui.bookmarks + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import kotlinx.android.synthetic.main.bookmark.view.* +import corewala.buran.R +import corewala.buran.io.database.bookmarks.BookmarkEntry +import corewala.visible + +class BookmarksAdapter(val onBookmark: (bookmarkEntry: BookmarkEntry) -> Unit, val onOverflow: (view: View, bookmarkEntry: BookmarkEntry, isFirst: Boolean, isLast: Boolean) -> Unit): RecyclerView.Adapter() { + + val bookmarks = mutableListOf() + + class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) + + fun update(bookmarkEntries: List){ + this.bookmarks.clear() + this.bookmarks.addAll(bookmarkEntries) + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + return ViewHolder( + LayoutInflater.from(parent.context).inflate(R.layout.bookmark, parent, false) + ) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val bookmark = bookmarks[position] + + if(bookmark.visible) { + holder.itemView.visible(true) + holder.itemView.bookmark_name.text = bookmark.label + holder.itemView.bookmark_uri.text = bookmark.uri.toString() + + holder.itemView.bookmark_layout.setOnClickListener { + onBookmark(bookmarks[holder.adapterPosition]) + } + + holder.itemView.bookmark_overflow.setOnClickListener { view -> + val isFirst = (holder.adapterPosition == 0) + val isLast = (holder.adapterPosition == bookmarks.size - 1) + onOverflow(view, bookmarks[holder.adapterPosition], isFirst, isLast) + } + }else{ + holder.itemView.visible(false) + } + } + + override fun getItemCount(): Int = bookmarks.size + + fun hide(bookmarkEntry: BookmarkEntry) { + bookmarkEntry.visible = false + notifyItemChanged(bookmarks.indexOf(bookmarkEntry)) + } + + fun show(bookmarkEntry: BookmarkEntry) { + bookmarkEntry.visible = true + notifyItemChanged(bookmarks.indexOf(bookmarkEntry)) + } + + fun remove(bookmarkEntry: BookmarkEntry){ + val index = bookmarks.indexOf(bookmarkEntry) + bookmarks.remove(bookmarkEntry) + notifyItemRemoved(index) + } +} \ No newline at end of file diff --git a/app/src/main/java/corewala/buran/ui/bookmarks/BookmarksDialog.kt b/app/src/main/java/corewala/buran/ui/bookmarks/BookmarksDialog.kt new file mode 100644 index 0000000..b49b5d0 --- /dev/null +++ b/app/src/main/java/corewala/buran/ui/bookmarks/BookmarksDialog.kt @@ -0,0 +1,250 @@ +package corewala.buran.ui.bookmarks + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import android.os.Handler +import android.os.Looper +import android.view.View +import android.widget.Toast +import androidx.appcompat.app.AppCompatDialog +import androidx.appcompat.widget.PopupMenu +import androidx.core.view.forEach +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.snackbar.BaseTransientBottomBar +import com.google.android.material.snackbar.Snackbar +import kotlinx.android.synthetic.main.dialog_bookmarks.view.* +import corewala.buran.R +import corewala.buran.io.database.bookmarks.BookmarkEntry +import corewala.buran.io.database.bookmarks.BookmarksDatasource +import corewala.buran.ui.CREATE_BOOKMARK_EXPORT_FILE_REQ +import corewala.buran.ui.CREATE_BOOKMARK_IMPORT_FILE_REQ +import corewala.visible +import org.json.JSONObject +import java.io.BufferedReader +import java.io.InputStreamReader +import java.lang.StringBuilder +import java.net.URI + + +class BookmarksDialog( + context: Activity, + private val bookmarkDatasource: BookmarksDatasource, + onBookmark: (bookmarkEntry: BookmarkEntry) -> Unit +): AppCompatDialog(context, R.style.FSDialog) { + + var bookmarksAdapter: BookmarksAdapter + + var view: View = View.inflate(context, R.layout.dialog_bookmarks, null) + + init { + + setContentView(view) + + view.bookmarks_toolbar.setNavigationIcon(R.drawable.vector_close) + view.bookmarks_toolbar.setNavigationOnClickListener { + dismiss() + } + + view.bookmarks_toolbar.menu.forEach { menu -> + menu.setOnMenuItemClickListener { item -> + when(item.itemId){ + R.id.menu_action_import_bookmarks -> { + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) + intent.addCategory(Intent.CATEGORY_OPENABLE) + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + intent.type = "application/json" + context.startActivityForResult(intent, CREATE_BOOKMARK_IMPORT_FILE_REQ) + } + R.id.menu_action_export_bookmarks -> { + val intent = Intent(Intent.ACTION_CREATE_DOCUMENT) + intent.addCategory(Intent.CATEGORY_OPENABLE) + intent.type = "application/json" + intent.putExtra(Intent.EXTRA_TITLE, "buran_bookmarks.json") + context.startActivityForResult(intent, CREATE_BOOKMARK_EXPORT_FILE_REQ) + } + else -> { + + } + } + true + } + } + + + + //None as yet + view.bookmarks_toolbar.inflateMenu(R.menu.add_bookmarks) + view.bookmarks_toolbar.setOnMenuItemClickListener { _ -> + true + } + + view.bookmarks_recycler.layoutManager = LinearLayoutManager(context) + + bookmarksAdapter = BookmarksAdapter({ bookmark -> + //onBookmark + onBookmark(bookmark) + dismiss() + + }){ view, bookmark, isFirst, isLast -> + //onOverflow + val bookmarkOverflow = PopupMenu(context, view) + + bookmarkOverflow.inflate(R.menu.menu_bookmark) + + if(isFirst) bookmarkOverflow.menu.removeItem(R.id.menu_bookmark_move_up) + if(isLast) bookmarkOverflow.menu.removeItem(R.id.menu_bookmark_move_down) + + bookmarkOverflow.setOnMenuItemClickListener { menuItem -> + when(menuItem.itemId){ + R.id.menu_bookmark_edit -> edit(bookmark) + R.id.menu_bookmark_delete -> delete(bookmark) + R.id.menu_bookmark_move_up -> moveUp(bookmark) + R.id.menu_bookmark_move_down -> moveDown(bookmark) + } + true + } + + bookmarkOverflow.show() + } + + view.bookmarks_recycler.adapter = bookmarksAdapter + + bookmarkDatasource.get { bookmarks -> + + Handler(Looper.getMainLooper()).post { + when { + bookmarks.isEmpty() -> view.bookmarks_empty_layout.visible(true) + else -> bookmarksAdapter.update(bookmarks) + } + } + } + } + + private fun edit(bookmarkEntry: BookmarkEntry){ + BookmarkDialog( + context, + BookmarkDialog.mode_edit, + null, + bookmarkEntry.uri.toString(), + bookmarkEntry.label + ){ label, uri -> + bookmarkDatasource.update(bookmarkEntry, label, uri){ + bookmarkDatasource.get { bookmarks -> + Handler(Looper.getMainLooper()).post { + bookmarksAdapter.update(bookmarks) + } + } + } + }.show() + } + + /** + * + * Bookmark isn't actually deleted from the DB until the Snackbar disappears. Which is nice. + * + */ + private fun delete(bookmarkEntry: BookmarkEntry){ + //OnDelete + bookmarksAdapter.hide(bookmarkEntry) + Snackbar.make(view, "Deleted ${bookmarkEntry.label}", Snackbar.LENGTH_SHORT).addCallback( + object : Snackbar.Callback() { + override fun onDismissed(transientBottomBar: Snackbar?, event: Int) = when (event) { + BaseTransientBottomBar.BaseCallback.DISMISS_EVENT_ACTION -> bookmarksAdapter.show( + bookmarkEntry + ) + else -> bookmarkDatasource.delete(bookmarkEntry) { + Handler(Looper.getMainLooper()).post { + bookmarksAdapter.remove(bookmarkEntry) + } + } + } + }).setAction("Undo"){ + //Action listener unused + }.show() + } + + private fun moveUp(bookmarkEntry: BookmarkEntry){ + bookmarkDatasource.moveUp(bookmarkEntry){ + bookmarkDatasource.get { bookmarks -> + Handler(Looper.getMainLooper()).post { + bookmarksAdapter.update(bookmarks) + } + } + } + } + + private fun moveDown(bookmarkEntry: BookmarkEntry){ + bookmarkDatasource.moveDown(bookmarkEntry){ + bookmarkDatasource.get { bookmarks -> + Handler(Looper.getMainLooper()).post { + bookmarksAdapter.update(bookmarks) + } + } + } + } + + fun bookmarksExportFileReady(uri: Uri){ + val model = BookmarksViewModel() + model.initialise(bookmarkDatasource){ + Handler(Looper.getMainLooper()).post { + Toast.makeText(context, "Bookmarks Exported", Toast.LENGTH_SHORT).show() + } + } + model.exportBookmarks(context.contentResolver, uri) + } + + fun bookmarksImportFileReady(uri: Uri){ + context.contentResolver.openInputStream(uri).use{ inputStream -> + InputStreamReader(inputStream).use { streamReader -> + BufferedReader(streamReader).use { bufferedReader -> + val sb = StringBuilder() + var line: String? + while (bufferedReader.readLine().also { line = it } != null) { + sb.append(line).append('\n') + } + val bookmarksRawJson = sb.toString() + val bookmarksJson = JSONObject(bookmarksRawJson) + val bookmarks = bookmarksJson.getJSONArray("bookmarks") + val bookmarkEntries = arrayListOf() + + var skipped = 0 + var added = 0 + + repeat(bookmarks.length()){ index -> + val bookmark = bookmarks.getJSONObject(index) + val bookmarkLabel = bookmark.getString("label") + val bookmarkUri = bookmark.getString("uri") + println("Importing bookmark: $bookmarkLabel : $uri") + val existing = bookmarksAdapter.bookmarks.filter { entry -> + entry.uri.toString() == bookmarkUri + } + when { + existing.isNotEmpty() -> skipped++ + else -> { + added++ + bookmarkEntries.add(BookmarkEntry(-1, bookmarkLabel, URI.create(bookmarkUri), index)) + } + } + } + + bookmarkDatasource.add(bookmarkEntries.toTypedArray()){ + bookmarkDatasource.get { bookmarks -> + Handler(Looper.getMainLooper()).post { + view.bookmarks_empty_layout.visible(false) + bookmarksAdapter.update(bookmarks) + when { + skipped > 0 -> { + Toast.makeText(context, "$added bookmarks imported ($skipped duplicates)", Toast.LENGTH_SHORT).show() + } + else -> Toast.makeText(context, "$added bookmarks imported", Toast.LENGTH_SHORT).show() + } + + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/corewala/buran/ui/bookmarks/BookmarksViewModel.kt b/app/src/main/java/corewala/buran/ui/bookmarks/BookmarksViewModel.kt new file mode 100644 index 0000000..fc4348a --- /dev/null +++ b/app/src/main/java/corewala/buran/ui/bookmarks/BookmarksViewModel.kt @@ -0,0 +1,59 @@ +package corewala.buran.ui.bookmarks + +import android.content.ContentResolver +import android.net.Uri +import corewala.buran.io.database.bookmarks.BookmarksDatasource +import org.json.JSONArray +import org.json.JSONObject +import java.io.FileOutputStream +import java.io.PrintStream + +/** + * + * Pseudo viewmodel for now until I can find time to refactor the entire dialog - putting new functionality here + * + */ +class BookmarksViewModel { + + lateinit var datasource: BookmarksDatasource + + var onExport: () -> Unit = {} + + fun initialise(datasource: BookmarksDatasource, onExport: () -> Unit){ + this.datasource = datasource + this.onExport = onExport + } + + + fun exportBookmarks(contentResolver: ContentResolver, uri: Uri){ + datasource.get { bookmarks -> + val json = JSONObject() + val bookmarksJson = JSONArray() + + + bookmarks.forEach { entry -> + val bookmarkJson = JSONObject() + bookmarkJson.put("label", entry.label) + bookmarkJson.put("uri", entry.uri) + bookmarksJson.put(bookmarkJson) + } + + json.put("bookmarks", bookmarksJson) + + val bookmarks = json.toString(2) + println("Bookmarks json to export: $bookmarks") + + contentResolver.openFileDescriptor(uri, "w")?.use { fileDescriptor -> + FileOutputStream(fileDescriptor.fileDescriptor).use { os -> + PrintStream(os).use{ + it.print(bookmarks) + it.flush() + it.close() + os.close() + onExport.invoke() + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/corewala/buran/ui/content_image/ImageDialog.kt b/app/src/main/java/corewala/buran/ui/content_image/ImageDialog.kt new file mode 100644 index 0000000..befbf53 --- /dev/null +++ b/app/src/main/java/corewala/buran/ui/content_image/ImageDialog.kt @@ -0,0 +1,62 @@ +package corewala.buran.ui.content_image + +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import android.view.MenuInflater +import android.view.View +import androidx.appcompat.app.AppCompatDialog +import androidx.appcompat.widget.PopupMenu +import kotlinx.android.synthetic.main.dialog_content_image.view.* +import corewala.buran.R +import corewala.buran.io.GemState +import java.io.FileOutputStream + +object ImageDialog { + + fun show(context: Context, state: GemState.ResponseImage, onDownloadRequest: (state: GemState.ResponseImage) -> Unit){ + val dialog = AppCompatDialog(context, R.style.AppTheme) + + val view = View.inflate(context, R.layout.dialog_content_image, null) + dialog.setContentView(view) + + view.image_view.setImageURI(state.cacheUri) + + view.close_image_content_dialog.setOnClickListener { + dialog.dismiss() + } + + view.image_overflow.setOnClickListener { + val overflowMenu = PopupMenu(context, view.image_overflow) + val inflater: MenuInflater = overflowMenu.menuInflater + inflater.inflate(R.menu.image_overflow_menu, overflowMenu.menu) + overflowMenu.setOnMenuItemClickListener { menuItem -> + if(menuItem.itemId == R.id.image_overflow_save_image){ + onDownloadRequest(state) + } + true + } + + overflowMenu.show() + } + + dialog.show() + } + + /** + * + * Save bitmap using Storage Access Framework Uri + * @param bitmap + * @param uri - must be a SAF Uri + * @param onComplete + */ + fun publicExport(context: Context, bitmap: Bitmap?, uri: Uri, onComplete: (uri: Uri) -> Unit) { + context.contentResolver.openFileDescriptor(uri, "w")?.use { + FileOutputStream(it.fileDescriptor).use { outputStream -> + bitmap?.compress(Bitmap.CompressFormat.JPEG, 90, outputStream) + } + bitmap?.recycle() + onComplete(uri) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/corewala/buran/ui/content_image/TouchImageView.java b/app/src/main/java/corewala/buran/ui/content_image/TouchImageView.java new file mode 100644 index 0000000..a1b1bb1 --- /dev/null +++ b/app/src/main/java/corewala/buran/ui/content_image/TouchImageView.java @@ -0,0 +1,304 @@ +package corewala.buran.ui.content_image; + +import android.content.Context; +import android.graphics.Matrix; +import android.graphics.PointF; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; +import android.util.Log; +import android.view.GestureDetector; +import android.view.MotionEvent; +import android.view.ScaleGestureDetector; +import android.view.View; + +/** + * + * From SO: https://stackoverflow.com/a/54474590/7641428 + * + * todo - Rewrite in Kotlin at some point, possibly, maybe, never + * + */ +public class TouchImageView extends androidx.appcompat.widget.AppCompatImageView implements GestureDetector.OnGestureListener, GestureDetector.OnDoubleTapListener { + + Matrix matrix; + + // We can be in one of these 3 states + static final int NONE = 0; + static final int DRAG = 1; + static final int ZOOM = 2; + int mode = NONE; + + // Remember some things for zooming + PointF last = new PointF(); + PointF start = new PointF(); + float minScale = 0.9f; + float maxScale = 3f; + float[] m; + + int viewWidth, viewHeight; + static final int CLICK = 3; + float saveScale = 1f; + protected float origWidth, origHeight; + int oldMeasuredWidth, oldMeasuredHeight; + + ScaleGestureDetector mScaleDetector; + + Context context; + + public TouchImageView(Context context) { + super(context); + sharedConstructing(context); + } + + public TouchImageView(Context context, AttributeSet attrs) { + super(context, attrs); + sharedConstructing(context); + } + + GestureDetector mGestureDetector; + + private void sharedConstructing(Context context) { + super.setClickable(true); + this.context = context; + mGestureDetector = new GestureDetector(context, this); + mGestureDetector.setOnDoubleTapListener(this); + + mScaleDetector = new ScaleGestureDetector(context, new ScaleListener()); + matrix = new Matrix(); + m = new float[9]; + setImageMatrix(matrix); + setScaleType(ScaleType.MATRIX); + + setOnTouchListener(new OnTouchListener() { + + @Override + public boolean onTouch(View v, MotionEvent event) { + mScaleDetector.onTouchEvent(event); + mGestureDetector.onTouchEvent(event); + + PointF curr = new PointF(event.getX(), event.getY()); + + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + last.set(curr); + start.set(last); + mode = DRAG; + break; + + case MotionEvent.ACTION_MOVE: + if (mode == DRAG) { + float deltaX = curr.x - last.x; + float deltaY = curr.y - last.y; + float fixTransX = getFixDragTrans(deltaX, viewWidth, + origWidth * saveScale); + float fixTransY = getFixDragTrans(deltaY, viewHeight, + origHeight * saveScale); + matrix.postTranslate(fixTransX, fixTransY); + fixTrans(); + last.set(curr.x, curr.y); + } + break; + + case MotionEvent.ACTION_UP: + mode = NONE; + int xDiff = (int) Math.abs(curr.x - start.x); + int yDiff = (int) Math.abs(curr.y - start.y); + if (xDiff < CLICK && yDiff < CLICK) + performClick(); + break; + + case MotionEvent.ACTION_POINTER_UP: + mode = NONE; + break; + } + + setImageMatrix(matrix); + invalidate(); + return true; // indicate event was handled + } + + }); + + invalidate(); + } + + public void setMaxZoom(float x) { + maxScale = x; + } + + @Override + public boolean onSingleTapConfirmed(MotionEvent e) { + return false; + } + + public void reset(){ + saveScale = 1f; + fixTrans(); + } + + @Override + public boolean onDoubleTap(MotionEvent e) { + // Double tap is detected + + float origScale = saveScale; + float mScaleFactor; + + if (saveScale == maxScale) { + saveScale = minScale; + mScaleFactor = minScale / origScale; + } else { + saveScale = maxScale; + mScaleFactor = maxScale / origScale; + } + + matrix.postScale(mScaleFactor, mScaleFactor, viewWidth / 2f, viewHeight / 2f); + + fixTrans(); + + return false; + } + + @Override + public boolean onDoubleTapEvent(MotionEvent e) { + return false; + } + + @Override + public boolean onDown(MotionEvent e) { + return false; + } + + @Override + public void onShowPress(MotionEvent e) { + + } + + @Override + public boolean onSingleTapUp(MotionEvent e) { + return false; + } + + @Override + public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { + return false; + } + + @Override + public void onLongPress(MotionEvent e) { + + } + + @Override + public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { + return false; + } + + private class ScaleListener extends ScaleGestureDetector.SimpleOnScaleGestureListener { + @Override + public boolean onScaleBegin(ScaleGestureDetector detector) { + mode = ZOOM; + return true; + } + + @Override + public boolean onScale(ScaleGestureDetector detector) { + float mScaleFactor = detector.getScaleFactor(); + float origScale = saveScale; + saveScale *= mScaleFactor; + if (saveScale > maxScale) { + saveScale = maxScale; + mScaleFactor = maxScale / origScale; + } else if (saveScale < minScale) { + saveScale = minScale; + mScaleFactor = minScale / origScale; + } + + if (origWidth * saveScale <= viewWidth || origHeight * saveScale <= viewHeight) { + matrix.postScale(mScaleFactor, mScaleFactor, viewWidth / 2f, viewHeight / 2f); + }else { + matrix.postScale(mScaleFactor, mScaleFactor, detector.getFocusX(), detector.getFocusY()); + } + + fixTrans(); + return true; + } + } + + void fixTrans() { + matrix.getValues(m); + float transX = m[Matrix.MTRANS_X]; + float transY = m[Matrix.MTRANS_Y]; + + float fixTransX = getFixTrans(transX, viewWidth, origWidth * saveScale); + float fixTransY = getFixTrans(transY, viewHeight, origHeight * saveScale); + + if (fixTransX != 0 || fixTransY != 0) matrix.postTranslate(fixTransX, fixTransY); + } + + float getFixTrans(float trans, float viewSize, float contentSize) { + float minTrans, maxTrans; + + if (contentSize <= viewSize) { + minTrans = 0; + maxTrans = viewSize - contentSize; + } else { + minTrans = viewSize - contentSize; + maxTrans = 0; + } + + if (trans < minTrans) return -trans + minTrans; + if (trans > maxTrans) return -trans + maxTrans; + return 0; + } + + float getFixDragTrans(float delta, float viewSize, float contentSize) { + if (contentSize <= viewSize) { + return 0; + } + return delta; + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + viewWidth = MeasureSpec.getSize(widthMeasureSpec); + viewHeight = MeasureSpec.getSize(heightMeasureSpec); + + // + // Rescales image on rotation + // + if (oldMeasuredHeight == viewWidth && oldMeasuredHeight == viewHeight || viewWidth == 0 || viewHeight == 0) return; + oldMeasuredHeight = viewHeight; + oldMeasuredWidth = viewWidth; + + if (saveScale == 1) { + // Fit to screen. + float scale; + + Drawable drawable = getDrawable(); + if (drawable == null || drawable.getIntrinsicWidth() == 0 || drawable.getIntrinsicHeight() == 0) return; + int bmWidth = drawable.getIntrinsicWidth(); + int bmHeight = drawable.getIntrinsicHeight(); + + Log.d("bmSize", "bmWidth: " + bmWidth + " bmHeight : " + bmHeight); + + float scaleX = (float) viewWidth / (float) bmWidth; + float scaleY = (float) viewHeight / (float) bmHeight; + scale = Math.min(scaleX, scaleY); + matrix.setScale(scale, scale); + + // Center the image + float redundantYSpace = (float) viewHeight - (scale * (float) bmHeight); + float redundantXSpace = (float) viewWidth - (scale * (float) bmWidth); + redundantYSpace /= (float) 2; + redundantXSpace /= (float) 2; + + matrix.postTranslate(redundantXSpace, redundantYSpace); + + origWidth = viewWidth - 2 * redundantXSpace; + origHeight = viewHeight - 2 * redundantYSpace; + setImageMatrix(matrix); + } + fixTrans(); + } +} \ No newline at end of file diff --git a/app/src/main/java/corewala/buran/ui/content_text/TextDialog.kt b/app/src/main/java/corewala/buran/ui/content_text/TextDialog.kt new file mode 100644 index 0000000..dc14272 --- /dev/null +++ b/app/src/main/java/corewala/buran/ui/content_text/TextDialog.kt @@ -0,0 +1,27 @@ +package corewala.buran.ui.content_text + +import android.content.Context +import android.view.View +import androidx.appcompat.app.AppCompatDialog +import kotlinx.android.synthetic.main.dialog_content_text.view.* +import corewala.buran.R +import corewala.buran.io.GemState + +object TextDialog { + + fun show(context: Context, state: GemState.ResponseText){ + val dialog = AppCompatDialog(context, R.style.AppTheme) + + val view = View.inflate(context, R.layout.dialog_content_text, null) + dialog.setContentView(view) + + view.text_content.text = state.content + + view.close_text_content_dialog.setOnClickListener { + dialog.dismiss() + } + + + dialog.show() + } +} \ No newline at end of file diff --git a/app/src/main/java/corewala/buran/ui/gemtext_adapters/AbstractGemtextAdapter.kt b/app/src/main/java/corewala/buran/ui/gemtext_adapters/AbstractGemtextAdapter.kt new file mode 100644 index 0000000..fef7c99 --- /dev/null +++ b/app/src/main/java/corewala/buran/ui/gemtext_adapters/AbstractGemtextAdapter.kt @@ -0,0 +1,34 @@ +package corewala.buran.ui.gemtext_adapters + +import android.net.Uri +import androidx.recyclerview.widget.RecyclerView +import java.net.URI + +const val GEMTEXT_ADAPTER_DEFAULT = 0 +const val GEMTEXT_ADAPTER_LARGE = 1 + +abstract class AbstractGemtextAdapter( + val typeId: Int, + val onLink: (link: URI, longTap: Boolean, adapterPosition: Int) -> Unit +): RecyclerView.Adapter() { + + var showInlineIcons: Boolean = false + var hideCodeBlocks: Boolean = false + + abstract fun render(lines: List) + abstract fun loadImage(position: Int, cacheUri: Uri) + abstract fun inlineIcons(visible: Boolean) + abstract fun hideCodeBlocks(hideCodeBlocks: Boolean) + + abstract fun inferTitle(): String? + + companion object{ + fun getDefault(onLink: (link: URI, longTap: Boolean, adapterPosition: Int) -> Unit): AbstractGemtextAdapter { + return DefaultGemtextAdapter(GEMTEXT_ADAPTER_DEFAULT, onLink) + } + + fun getLargeGmi(onLink: (link: URI, longTap: Boolean, adapterPosition: Int) -> Unit): AbstractGemtextAdapter { + return LargeGemtextAdapter(GEMTEXT_ADAPTER_LARGE, onLink) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/corewala/buran/ui/gemtext_adapters/DefaultGemtextAdapter.kt b/app/src/main/java/corewala/buran/ui/gemtext_adapters/DefaultGemtextAdapter.kt new file mode 100644 index 0000000..748786e --- /dev/null +++ b/app/src/main/java/corewala/buran/ui/gemtext_adapters/DefaultGemtextAdapter.kt @@ -0,0 +1,256 @@ +package corewala.buran.ui.gemtext_adapters + +import android.annotation.SuppressLint +import android.net.Uri +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import kotlinx.android.synthetic.main.gemtext_code_block.view.* +import kotlinx.android.synthetic.main.gemtext_image_link.view.* +import kotlinx.android.synthetic.main.gemtext_link.view.gemtext_text_link +import kotlinx.android.synthetic.main.gemtext_text.view.* +import corewala.buran.R +import corewala.endsWithImage +import corewala.visible +import java.net.URI + +class DefaultGemtextAdapter( + typeId: Int, + onLink: (link: URI, longTap: Boolean, adapterPosition: Int) -> Unit) + : AbstractGemtextAdapter(typeId, onLink) { + + private var lines = mutableListOf() + private var inlineImages = HashMap() + + private val typeText = 0 + private val typeH1 = 1 + private val typeH2 = 2 + private val typeH3 = 3 + private val typeListItem = 4 + private val typeImageLink = 5 + private val typeLink = 6 + private val typeCodeBlock = 7 + private val typeQuote = 8 + + override fun render(lines: List){ + this.inlineImages.clear() + this.lines.clear() + this.lines.addAll(lines) + notifyDataSetChanged() + } + + private fun inflate(parent: ViewGroup, layout: Int): View{ + return LayoutInflater.from(parent.context).inflate(layout, parent, false) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GmiViewHolder { + return when(viewType){ + typeText -> GmiViewHolder.Text(inflate(parent, R.layout.gemtext_text)) + typeH1 -> GmiViewHolder.H1(inflate(parent, R.layout.gemtext_h1)) + typeH2 -> GmiViewHolder.H2(inflate(parent, R.layout.gemtext_h2)) + typeH3 -> GmiViewHolder.H3(inflate(parent, R.layout.gemtext_h3)) + typeListItem -> GmiViewHolder.ListItem(inflate(parent, R.layout.gemtext_text)) + typeImageLink -> GmiViewHolder.ImageLink(inflate(parent, R.layout.gemtext_image_link)) + typeLink -> GmiViewHolder.Link(inflate(parent, R.layout.gemtext_link)) + typeCodeBlock-> GmiViewHolder.Code(inflate(parent, R.layout.gemtext_code_block)) + typeQuote -> GmiViewHolder.Quote(inflate(parent, R.layout.gemtext_quote)) + else -> GmiViewHolder.Text(inflate(parent, R.layout.gemtext_text)) + } + } + + override fun getItemViewType(position: Int): Int { + val line = lines[position] + return when { + line.startsWith("```") -> typeCodeBlock + line.startsWith("###") -> typeH3 + line.startsWith("##") -> typeH2 + line.startsWith("#") -> typeH1 + line.startsWith("*") -> typeListItem + line.startsWith("=>") && getLink(line).endsWithImage() -> typeImageLink + line.startsWith("=>") -> typeLink + line.startsWith(">") -> typeQuote + else -> typeText + } + } + + override fun getItemCount(): Int = lines.size + + @SuppressLint("SetTextI18n") + override fun onBindViewHolder(holder: GmiViewHolder, position: Int) { + val line = lines[position] + + when(holder){ + is GmiViewHolder.Text -> holder.itemView.gemtext_text_textview.text = line + is GmiViewHolder.Code -> { + + var altText: String? = null + + if(line.startsWith("```<|ALT|>")){ + //there's alt text: "```<|ALT|>$alt" + altText = line.substring(10, line.indexOf("")) + holder.itemView.gemtext_text_monospace_textview.text = line.substring(line.indexOf("") + 7) + }else{ + holder.itemView.gemtext_text_monospace_textview.text = line.substring(3) + } + + if(hideCodeBlocks){ + holder.itemView.show_code_block.setText(R.string.show_code)//reset for recycling + altText?.let{ + holder.itemView.show_code_block.append(": $altText") + } + holder.itemView.show_code_block.visible(true) + holder.itemView.show_code_block.paint.isUnderlineText = true + holder.itemView.show_code_block.setOnClickListener { + setupCodeBlockToggle(holder, altText) + } + holder.itemView.gemtext_text_monospace_textview.visible(false) + + when { + showInlineIcons -> holder.itemView.show_code_block.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.vector_code, 0) + else -> holder.itemView.show_code_block.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0) + } + }else{ + holder.itemView.show_code_block.visible(false) + holder.itemView.gemtext_text_monospace_textview.visible(true) + } + } + is GmiViewHolder.Quote -> holder.itemView.gemtext_text_monospace_textview.text = line.substring(1).trim() + is GmiViewHolder.H1 -> { + when { + line.length > 2 -> holder.itemView.gemtext_text_textview.text = line.substring(2).trim() + else -> holder.itemView.gemtext_text_textview.text = "" + } + } + is GmiViewHolder.H2 -> { + when { + line.length > 3 -> holder.itemView.gemtext_text_textview.text = line.substring(3).trim() + else -> holder.itemView.gemtext_text_textview.text = "" + } + } + is GmiViewHolder.H3 -> { + when { + line.length > 4 -> holder.itemView.gemtext_text_textview.text = line.substring(4).trim() + else -> holder.itemView.gemtext_text_textview.text = "" + } + } + is GmiViewHolder.ListItem -> holder.itemView.gemtext_text_textview.text = "• ${line.substring(1)}".trim() + is GmiViewHolder.Link -> { + val linkParts = line.substring(2).trim().split("\\s+".toRegex(), 2) + var linkName = linkParts[0] + + if(linkParts.size > 1) linkName = linkParts[1] + + val displayText = linkName + holder.itemView.gemtext_text_link.text = displayText + holder.itemView.gemtext_text_link.paint.isUnderlineText = true + + when { + showInlineIcons && linkParts.first().startsWith("http") -> holder.itemView.gemtext_text_link.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.vector_open_browser, 0) + else -> holder.itemView.gemtext_text_link.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0) + } + + holder.itemView.gemtext_text_link.setOnClickListener { + val uri = getUri(lines[holder.adapterPosition]) + println("User clicked link: $uri") + onLink(uri, false, holder.adapterPosition) + + } + holder.itemView.gemtext_text_link.setOnLongClickListener { + val uri = getUri(lines[holder.adapterPosition]) + println("User long-clicked link: $uri") + onLink(uri, true, holder.adapterPosition) + true + } + } + is GmiViewHolder.ImageLink -> { + val linkParts = line.substring(2).trim().split("\\s+".toRegex(), 2) + var linkName = linkParts[0] + + if(linkParts.size > 1) linkName = linkParts[1] + + val displayText = linkName + holder.itemView.gemtext_text_link.text = displayText + holder.itemView.gemtext_text_link.paint.isUnderlineText = true + holder.itemView.gemtext_text_link.setOnClickListener { + val uri = getUri(lines[holder.adapterPosition]) + println("User clicked link: $uri") + onLink(uri, false, holder.adapterPosition) + + } + holder.itemView.gemtext_text_link.setOnLongClickListener { + val uri = getUri(lines[holder.adapterPosition]) + println("User long-clicked link: $uri") + onLink(uri, true, holder.adapterPosition) + true + } + + when { + inlineImages.containsKey(position) -> { + holder.itemView.gemtext_inline_image.visible(true) + holder.itemView.gemtext_inline_image.setImageURI(inlineImages[position]) + } + else -> holder.itemView.gemtext_inline_image.visible(false) + } + + when { + showInlineIcons -> holder.itemView.gemtext_text_link.setCompoundDrawablesWithIntrinsicBounds(0, 0, R.drawable.vector_photo, 0) + else -> holder.itemView.gemtext_text_link.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0) + } + } + } + } + + private fun setupCodeBlockToggle(holder: GmiViewHolder.Code, altText: String?) { + //val adapterPosition = holder.adapterPosition + when { + holder.itemView.gemtext_text_monospace_textview.isVisible -> { + holder.itemView.show_code_block.setText(R.string.show_code) + holder.itemView.gemtext_text_monospace_textview.visible(false) + altText?.let{ + holder.itemView.show_code_block.append(": $altText") + } + } + else -> { + holder.itemView.show_code_block.setText(R.string.hide_code) + holder.itemView.gemtext_text_monospace_textview.visible(true) + altText?.let{ + holder.itemView.show_code_block.append(": $altText") + } + } + } + } + + private fun getLink(line: String): String{ + val linkParts = line.substring(2).trim().split("\\s+".toRegex(), 2) + return linkParts[0] + } + + private fun getUri(linkLine: String): URI{ + val linkParts = linkLine.substring(2).trim().split("\\s+".toRegex(), 2) + return URI.create(linkParts.first()) + } + + override fun inferTitle(): String? { + lines.forEach { line -> + if(line.startsWith("#")) return line.replace("#", "").trim() + } + + return null + } + + override fun loadImage(position: Int, cacheUri: Uri){ + inlineImages[position] = cacheUri + notifyItemChanged(position) + } + + override fun inlineIcons(visible: Boolean){ + this.showInlineIcons = visible + notifyDataSetChanged() + } + + override fun hideCodeBlocks(hideCodeBlocks: Boolean) { + this.hideCodeBlocks = hideCodeBlocks + notifyDataSetChanged() + } +} \ No newline at end of file diff --git a/app/src/main/java/corewala/buran/ui/gemtext_adapters/GmiViewHolder.kt b/app/src/main/java/corewala/buran/ui/gemtext_adapters/GmiViewHolder.kt new file mode 100644 index 0000000..68764c7 --- /dev/null +++ b/app/src/main/java/corewala/buran/ui/gemtext_adapters/GmiViewHolder.kt @@ -0,0 +1,16 @@ +package corewala.buran.ui.gemtext_adapters + +import android.view.View +import androidx.recyclerview.widget.RecyclerView + +sealed class GmiViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView){ + class Text(itemView: View): GmiViewHolder(itemView) + class H1(itemView: View): GmiViewHolder(itemView) + class H2(itemView: View): GmiViewHolder(itemView) + class H3(itemView: View): GmiViewHolder(itemView) + class ListItem(itemView: View): GmiViewHolder(itemView) + class ImageLink(itemView: View): GmiViewHolder(itemView) + class Link(itemView: View): GmiViewHolder(itemView) + class Code(itemView: View): GmiViewHolder(itemView) + class Quote(itemView: View): GmiViewHolder(itemView) +} \ No newline at end of file diff --git a/app/src/main/java/corewala/buran/ui/gemtext_adapters/LargeGemtextAdapter.kt b/app/src/main/java/corewala/buran/ui/gemtext_adapters/LargeGemtextAdapter.kt new file mode 100644 index 0000000..528985e --- /dev/null +++ b/app/src/main/java/corewala/buran/ui/gemtext_adapters/LargeGemtextAdapter.kt @@ -0,0 +1,253 @@ +package corewala.buran.ui.gemtext_adapters + +import android.annotation.SuppressLint +import android.net.Uri +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import kotlinx.android.synthetic.main.gemtext_large_code_block.view.* +import kotlinx.android.synthetic.main.gemtext_large_image_link.view.* +import kotlinx.android.synthetic.main.gemtext_large_link.view.gemtext_text_link +import kotlinx.android.synthetic.main.gemtext_large_text.view.* +import corewala.buran.R +import corewala.endsWithImage +import corewala.visible +import java.net.URI + +class LargeGemtextAdapter( + typeId: Int, + onLink: (link: URI, longTap: Boolean, adapterPosition: Int) -> Unit) + : AbstractGemtextAdapter(typeId, onLink) { + + private var lines = mutableListOf() + private var inlineImages = HashMap() + + private val typeText = 0 + private val typeH1 = 1 + private val typeH2 = 2 + private val typeH3 = 3 + private val typeListItem = 4 + private val typeImageLink = 5 + private val typeLink = 6 + private val typeCodeBlock = 7 + private val typeQuote = 8 + + override fun render(lines: List){ + this.inlineImages.clear() + this.lines.clear() + this.lines.addAll(lines) + notifyDataSetChanged() + } + + private fun inflate(parent: ViewGroup, layout: Int): View{ + return LayoutInflater.from(parent.context).inflate(layout, parent, false) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GmiViewHolder { + return when(viewType){ + typeText -> GmiViewHolder.Text(inflate(parent, R.layout.gemtext_large_text)) + typeH1 -> GmiViewHolder.H1(inflate(parent, R.layout.gemtext_large_h1)) + typeH2 -> GmiViewHolder.H2(inflate(parent, R.layout.gemtext_large_h2)) + typeH3 -> GmiViewHolder.H3(inflate(parent, R.layout.gemtext_large_h3)) + typeListItem -> GmiViewHolder.ListItem(inflate(parent, R.layout.gemtext_large_text)) + typeImageLink -> GmiViewHolder.ImageLink(inflate(parent, R.layout.gemtext_large_image_link)) + typeLink -> GmiViewHolder.Link(inflate(parent, R.layout.gemtext_large_link)) + typeCodeBlock-> GmiViewHolder.Code(inflate(parent, R.layout.gemtext_large_code_block)) + typeQuote -> GmiViewHolder.Quote(inflate(parent, R.layout.gemtext_large_quote)) + else -> GmiViewHolder.Text(inflate(parent, R.layout.gemtext_large_text)) + } + } + + override fun getItemViewType(position: Int): Int { + val line = lines[position] + return when { + line.startsWith("```") -> typeCodeBlock + line.startsWith("###") -> typeH3 + line.startsWith("##") -> typeH2 + line.startsWith("#") -> typeH1 + line.startsWith("*") -> typeListItem + line.startsWith("=>") && getLink(line).endsWithImage() -> typeImageLink + line.startsWith("=>") -> typeLink + line.startsWith(">") -> typeQuote + else -> typeText + } + } + + override fun getItemCount(): Int = lines.size + + @SuppressLint("SetTextI18n") + override fun onBindViewHolder(holder: GmiViewHolder, position: Int) { + val line = lines[position] + + when(holder){ + is GmiViewHolder.Text -> holder.itemView.gemtext_text_textview.text = line + is GmiViewHolder.Code -> { + + var altText: String? = null + + if(line.startsWith("```<|ALT|>")){ + //there's alt text: "```<|ALT|>$alt" + altText = line.substring(10, line.indexOf("")) + holder.itemView.gemtext_text_monospace_textview.text = line.substring(line.indexOf("") + 7) + }else{ + holder.itemView.gemtext_text_monospace_textview.text = line.substring(3) + } + + if(hideCodeBlocks){ + holder.itemView.show_code_block.setText(R.string.show_code)//reset for recycling + altText?.let{ + holder.itemView.show_code_block.append(": $altText") + } + holder.itemView.show_code_block.visible(true) + holder.itemView.show_code_block.setOnClickListener { + setupCodeBlockToggle(holder, altText) + } + holder.itemView.gemtext_text_monospace_textview.visible(false) + + when { + showInlineIcons -> holder.itemView.show_code_block.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.vector_code, 0) + else -> holder.itemView.show_code_block.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0) + } + }else{ + holder.itemView.show_code_block.visible(false) + holder.itemView.gemtext_text_monospace_textview.visible(true) + } + } + is GmiViewHolder.Quote -> holder.itemView.gemtext_text_monospace_textview.text = line.substring(1).trim() + is GmiViewHolder.H1 -> { + when { + line.length > 2 -> holder.itemView.gemtext_text_textview.text = line.substring(2).trim() + else -> holder.itemView.gemtext_text_textview.text = "" + } + } + is GmiViewHolder.H2 -> { + when { + line.length > 3 -> holder.itemView.gemtext_text_textview.text = line.substring(3).trim() + else -> holder.itemView.gemtext_text_textview.text = "" + } + } + is GmiViewHolder.H3 -> { + when { + line.length > 4 -> holder.itemView.gemtext_text_textview.text = line.substring(4).trim() + else -> holder.itemView.gemtext_text_textview.text = "" + } + } + is GmiViewHolder.ListItem -> holder.itemView.gemtext_text_textview.text = "• ${line.substring(1)}".trim() + is GmiViewHolder.Link -> { + val linkParts = line.substring(2).trim().split("\\s+".toRegex(), 2) + var linkName = linkParts[0] + + if(linkParts.size > 1) linkName = linkParts[1] + + val displayText = linkName + holder.itemView.gemtext_text_link.text = displayText + + when { + showInlineIcons && linkParts.first().startsWith("http") -> holder.itemView.gemtext_text_link.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.vector_open_browser, 0) + else -> holder.itemView.gemtext_text_link.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0) + } + + holder.itemView.gemtext_text_link.setOnClickListener { + val uri = getUri(lines[holder.adapterPosition]) + println("User clicked link: $uri") + onLink(uri, false, holder.adapterPosition) + + } + holder.itemView.gemtext_text_link.setOnLongClickListener { + val uri = getUri(lines[holder.adapterPosition]) + println("User long-clicked link: $uri") + onLink(uri, true, holder.adapterPosition) + true + } + } + is GmiViewHolder.ImageLink -> { + val linkParts = line.substring(2).trim().split("\\s+".toRegex(), 2) + var linkName = linkParts[0] + + if(linkParts.size > 1) linkName = linkParts[1] + + val displayText = linkName + holder.itemView.gemtext_text_link.text = displayText + holder.itemView.gemtext_text_link.setOnClickListener { + val uri = getUri(lines[holder.adapterPosition]) + println("User clicked link: $uri") + onLink(uri, false, holder.adapterPosition) + + } + holder.itemView.gemtext_text_link.setOnLongClickListener { + val uri = getUri(lines[holder.adapterPosition]) + println("User long-clicked link: $uri") + onLink(uri, true, holder.adapterPosition) + true + } + + when { + inlineImages.containsKey(position) -> { + holder.itemView.gemtext_inline_image.visible(true) + holder.itemView.gemtext_inline_image.setImageURI(inlineImages[position]) + } + else -> holder.itemView.gemtext_inline_image.visible(false) + } + + when { + showInlineIcons -> holder.itemView.gemtext_text_link.setCompoundDrawablesWithIntrinsicBounds(0, 0, R.drawable.vector_photo, 0) + else -> holder.itemView.gemtext_text_link.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0) + } + } + } + } + + private fun setupCodeBlockToggle(holder: GmiViewHolder.Code, altText: String?) { + //val adapterPosition = holder.adapterPosition + when { + holder.itemView.gemtext_text_monospace_textview.isVisible -> { + holder.itemView.show_code_block.setText(R.string.show_code) + holder.itemView.gemtext_text_monospace_textview.visible(false) + altText?.let{ + holder.itemView.show_code_block.append(": $altText") + } + } + else -> { + holder.itemView.show_code_block.setText(R.string.hide_code) + holder.itemView.gemtext_text_monospace_textview.visible(true) + altText?.let{ + holder.itemView.show_code_block.append(": $altText") + } + } + } + } + + private fun getLink(line: String): String{ + val linkParts = line.substring(2).trim().split("\\s+".toRegex(), 2) + return linkParts[0] + } + + private fun getUri(linkLine: String): URI{ + val linkParts = linkLine.substring(2).trim().split("\\s+".toRegex(), 2) + return URI.create(linkParts.first()) + } + + override fun inferTitle(): String? { + lines.forEach { line -> + if(line.startsWith("#")) return line.replace("#", "").trim() + } + + return null + } + + override fun loadImage(position: Int, cacheUri: Uri){ + inlineImages[position] = cacheUri + notifyItemChanged(position) + } + + override fun inlineIcons(visible: Boolean){ + this.showInlineIcons = visible + notifyDataSetChanged() + } + + override fun hideCodeBlocks(hideCodeBlocks: Boolean) { + this.hideCodeBlocks = hideCodeBlocks + notifyDataSetChanged() + } +} \ No newline at end of file diff --git a/app/src/main/java/corewala/buran/ui/modals_menus/LinkPopup.kt b/app/src/main/java/corewala/buran/ui/modals_menus/LinkPopup.kt new file mode 100644 index 0000000..2e7dfb3 --- /dev/null +++ b/app/src/main/java/corewala/buran/ui/modals_menus/LinkPopup.kt @@ -0,0 +1,34 @@ +package corewala.buran.ui.modals_menus + +import android.view.MenuInflater +import android.view.View +import androidx.appcompat.widget.PopupMenu +import corewala.buran.R +import corewala.endsWithImage +import corewala.isWeb +import java.net.URI + +object LinkPopup { + + fun show(view: View?, uri: URI, onMenuOption: (menuId: Int) -> Unit){ + if(view != null) { + + val popup = PopupMenu(view.context, view) + val inflater: MenuInflater = popup.menuInflater + + val uriStr = uri.toString() + + when { + uriStr.endsWithImage() && !uriStr.isWeb() -> inflater.inflate(R.menu.image_link_menu, popup.menu) + else -> inflater.inflate(R.menu.link_menu, popup.menu) + } + + popup.setOnMenuItemClickListener { menuItem -> + onMenuOption(menuItem.itemId) + true + } + + popup.show() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/corewala/buran/ui/modals_menus/about/AboutDialog.kt b/app/src/main/java/corewala/buran/ui/modals_menus/about/AboutDialog.kt new file mode 100644 index 0000000..13e302a --- /dev/null +++ b/app/src/main/java/corewala/buran/ui/modals_menus/about/AboutDialog.kt @@ -0,0 +1,43 @@ +package corewala.buran.ui.modals_menus.about + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Handler +import android.os.Looper +import android.view.View +import androidx.appcompat.app.AppCompatDialog +import androidx.appcompat.widget.AppCompatTextView +import kotlinx.android.synthetic.main.dialog_about.view.* +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import corewala.buran.R +import java.lang.StringBuilder +import java.security.SecureRandom +import java.security.Security +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLSocket +import javax.net.ssl.SSLSocketFactory + +object AboutDialog { + + fun show(context: Context){ + val dialog = AppCompatDialog(context, R.style.AppTheme) + + val view = View.inflate(context, R.layout.dialog_about, null) + dialog.setContentView(view) + + view.close_tab_dialog.setOnClickListener { + dialog.dismiss() + } + + view.source_button.setOnClickListener { + context.startActivity(Intent(Intent.ACTION_VIEW).apply { + data = Uri.parse("https://github.com/Corewala/Buran") + }) + } + + dialog.show() + } + +} \ No newline at end of file diff --git a/app/src/main/java/corewala/buran/ui/modals_menus/history/HistoryAdapter.kt b/app/src/main/java/corewala/buran/ui/modals_menus/history/HistoryAdapter.kt new file mode 100644 index 0000000..ffa837c --- /dev/null +++ b/app/src/main/java/corewala/buran/ui/modals_menus/history/HistoryAdapter.kt @@ -0,0 +1,30 @@ +package corewala.buran.ui.modals_menus.history + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import kotlinx.android.synthetic.main.row_history.view.* +import corewala.delay +import corewala.buran.R +import corewala.buran.io.database.history.HistoryEntry + +class HistoryAdapter(val history: List, val onClick:(entry: HistoryEntry) -> Unit): RecyclerView.Adapter() { + + class ViewHolder(view: View): RecyclerView.ViewHolder(view) + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + return ViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.row_history, parent, false)) + } + + override fun getItemCount(): Int = history.size + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.itemView.history_address.text = history[position].uri.toString() + holder.itemView.history_row.setOnClickListener { + delay(500){ + onClick(history[holder.adapterPosition]) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/corewala/buran/ui/modals_menus/history/HistoryDialog.kt b/app/src/main/java/corewala/buran/ui/modals_menus/history/HistoryDialog.kt new file mode 100644 index 0000000..8df26eb --- /dev/null +++ b/app/src/main/java/corewala/buran/ui/modals_menus/history/HistoryDialog.kt @@ -0,0 +1,64 @@ +package corewala.buran.ui.modals_menus.history + +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.view.MenuInflater +import android.view.View +import android.widget.Toast +import androidx.appcompat.app.AppCompatDialog +import androidx.appcompat.widget.PopupMenu +import androidx.core.view.MenuCompat +import androidx.recyclerview.widget.LinearLayoutManager +import kotlinx.android.synthetic.main.dialog_about.view.close_tab_dialog +import kotlinx.android.synthetic.main.dialog_history.view.* +import corewala.buran.R +import corewala.buran.io.database.history.BuranHistory + +object HistoryDialog { + fun show(context: Context, history: BuranHistory, onHistoryItem: (address: String) -> Unit){ + val dialog = AppCompatDialog(context, R.style.AppTheme) + + val view = View.inflate(context, R.layout.dialog_history, null) + dialog.setContentView(view) + + view.close_tab_dialog.setOnClickListener { + dialog.dismiss() + } + + view.history_overflow.setOnClickListener { + val popup = PopupMenu(view.context, view.history_overflow) + val inflater: MenuInflater = popup.menuInflater + inflater.inflate(R.menu.history_overflow_menu, popup.menu) + popup.setOnMenuItemClickListener { menuItem -> + if(menuItem.itemId == R.id.history_overflow_clear_history){ + history.clear { + Handler(Looper.getMainLooper()).post { + dialog.dismiss() + Toast.makeText(context, context.getString(R.string.history_cleared), Toast.LENGTH_SHORT).show() + } + } + }else if(menuItem.itemId == R.id.history_overflow_clear_runtime_cache){ + dialog.dismiss() + Toast.makeText(context, context.getString(R.string.runtime_cahce_cleared), Toast.LENGTH_SHORT).show() + } + true + } + MenuCompat.setGroupDividerEnabled(popup.menu, true) + popup.show() + } + + view.history_recycler.layoutManager = LinearLayoutManager(context) + + history.get { history -> + Handler(Looper.getMainLooper()).post { + view.history_recycler.adapter = HistoryAdapter(history) { entry -> + onHistoryItem(entry.uri.toString()) + dialog.dismiss() + } + + dialog.show() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/corewala/buran/ui/modals_menus/input/InputDialog.kt b/app/src/main/java/corewala/buran/ui/modals_menus/input/InputDialog.kt new file mode 100644 index 0000000..6eb0f74 --- /dev/null +++ b/app/src/main/java/corewala/buran/ui/modals_menus/input/InputDialog.kt @@ -0,0 +1,33 @@ +package corewala.buran.ui.modals_menus.input + +import android.content.Context +import android.view.View +import androidx.appcompat.app.AppCompatDialog +import kotlinx.android.synthetic.main.dialog_input_query.view.* +import corewala.buran.R +import corewala.buran.io.GemState +import java.net.URLEncoder + +object InputDialog { + + fun show(context: Context, state: GemState.ResponseInput, onQuery: (queryAddress: String) -> Unit) { + val dialog = AppCompatDialog(context, R.style.AppTheme) + + val view = View.inflate(context, R.layout.dialog_input_query, null) + dialog.setContentView(view) + + view.close_input_query_dialog.setOnClickListener { + dialog.dismiss() + } + + view.query_text.text = state.header.meta + + view.query_submit_button.setOnClickListener { + val encoded = URLEncoder.encode(view.query_input.text.toString(), "UTF-8") + onQuery("${state.uri}?$encoded") + dialog.dismiss() + } + + dialog.show() + } +} \ No newline at end of file diff --git a/app/src/main/java/corewala/buran/ui/modals_menus/overflow/OverflowPopup.kt b/app/src/main/java/corewala/buran/ui/modals_menus/overflow/OverflowPopup.kt new file mode 100644 index 0000000..7295260 --- /dev/null +++ b/app/src/main/java/corewala/buran/ui/modals_menus/overflow/OverflowPopup.kt @@ -0,0 +1,64 @@ +package corewala.buran.ui.modals_menus.overflow + +import android.content.Context +import android.graphics.Color +import android.graphics.drawable.Drawable +import android.text.SpannableStringBuilder +import android.text.style.ImageSpan +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import androidx.appcompat.widget.PopupMenu +import androidx.core.view.MenuCompat +import corewala.buran.R + + +object OverflowPopup { + + fun show(view: View?, onMenuOption: (menuId: Int) -> Unit){ + if(view != null) { + val popup = PopupMenu(view.context, view) + val inflater: MenuInflater = popup.menuInflater + inflater.inflate(R.menu.overflow_menu, popup.menu) + popup.setOnMenuItemClickListener { menuItem -> + onMenuOption(menuItem.itemId) + true + } + MenuCompat.setGroupDividerEnabled(popup.menu, true) + //insertMenuItemIcons(view.context, popup) + popup.show() + } + } + + fun insertMenuItemIcons(context: Context, popupMenu: PopupMenu) { + val menu: Menu = popupMenu.menu + if (hasIcon(menu)) { + for (i in 0 until menu.size()) { + insertMenuItemIcon(context, menu.getItem(i)) + } + } + } + + private fun hasIcon(menu: Menu): Boolean { + for (i in 0 until menu.size()) { + if (menu.getItem(i).icon != null) return true + } + return false + } + + /** + * Converts the given MenuItem's title into a Spannable containing both its icon and title. + */ + private fun insertMenuItemIcon(context: Context, menuItem: MenuItem) { + val icon: Drawable = menuItem.icon + val iconSize = context.resources.getDimensionPixelSize(R.dimen.menu_item_icon_size) + icon.setBounds(0, 0, iconSize, iconSize) + icon.setTint(Color.WHITE) + val imageSpan = ImageSpan(icon) + val ssb = SpannableStringBuilder(" " + menuItem.title) + ssb.setSpan(imageSpan, 1, 2, 0) + menuItem.title = ssb + menuItem.icon = null + } +} \ No newline at end of file diff --git a/app/src/main/java/corewala/buran/ui/settings/SettingsActivity.kt b/app/src/main/java/corewala/buran/ui/settings/SettingsActivity.kt new file mode 100644 index 0000000..53c9f4d --- /dev/null +++ b/app/src/main/java/corewala/buran/ui/settings/SettingsActivity.kt @@ -0,0 +1,25 @@ +package corewala.buran.ui.settings + +import android.os.Bundle +import android.view.MenuItem +import androidx.appcompat.app.AppCompatActivity +import corewala.buran.R + +class SettingsActivity: AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContentView(R.layout.activity_settings) + + setSupportActionBar(findViewById(R.id.settings_toolbar)) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + supportActionBar?.setHomeAsUpIndicator(R.drawable.vector_close) + + supportFragmentManager.beginTransaction().replace(R.id.settings_container, SettingsFragment()).commit() + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + finish() + return super.onOptionsItemSelected(item) + } +} diff --git a/app/src/main/java/corewala/buran/ui/settings/SettingsFragment.kt b/app/src/main/java/corewala/buran/ui/settings/SettingsFragment.kt new file mode 100644 index 0000000..b7a36e7 --- /dev/null +++ b/app/src/main/java/corewala/buran/ui/settings/SettingsFragment.kt @@ -0,0 +1,455 @@ +package corewala.buran.ui.settings + +import android.app.Activity.RESULT_OK +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.net.Uri +import android.os.Bundle +import android.provider.OpenableColumns +import android.view.View +import android.view.inputmethod.EditorInfo +import androidx.appcompat.app.AppCompatDelegate +import androidx.preference.* +import corewala.buran.Buran +import corewala.buran.R +import java.security.SecureRandom +import java.util.* +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLSocket +import javax.net.ssl.SSLSocketFactory + + +const val PREFS_SET_CLIENT_CERT_REQ = 20 + +class SettingsFragment: PreferenceFragmentCompat(), Preference.OnPreferenceChangeListener { + + lateinit var prefs: SharedPreferences + lateinit var protocols: Array + + private lateinit var clientCertPref: Preference + private lateinit var useClientCertPreference: SwitchPreferenceCompat + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + + prefs = preferenceManager.sharedPreferences + + val context = preferenceManager.context + val screen = preferenceManager.createPreferenceScreen(context) + + /** + * Buran App Settings + */ + val appCategory = PreferenceCategory(context) + appCategory.key = "app_category" + appCategory.title = getString(R.string.configure_buran) + screen.addPreference(appCategory) + + //Home --------------------------------------------- + val homePreference = EditTextPreference(context) + homePreference.title = getString(R.string.home_capsule) + homePreference.key = "home_capsule" + homePreference.dialogTitle = getString(R.string.home_capsule) + + val homecapsule = preferenceManager.sharedPreferences.getString( + "home_capsule", + Buran.DEFAULT_HOME_CAPSULE + ) + + homePreference.summary = homecapsule + homePreference.positiveButtonText = getString(R.string.update) + homePreference.negativeButtonText = getString(R.string.cancel) + homePreference.title = getString(R.string.home_capsule) + homePreference.setOnPreferenceChangeListener { _, newValue -> + homePreference.summary = newValue.toString() + true + } + homePreference.setOnBindEditTextListener{ editText -> + editText.imeOptions = EditorInfo.IME_ACTION_DONE + editText.setSelection(editText.text.toString().length)//Set caret position to end + } + appCategory.addPreference(homePreference) + + //Home - Certificates + buildClientCertificateSection(context, appCategory) + + //Theme -------------------------------------------- + buildThemeSection(context, appCategory) + + //Accessibility ------------------------------------ + buildsAccessibility(context, screen) + + //Web ---------------------------------------------- + buildWebSection(context, screen) + + //TLS ---------------------------------------------- + buildTLSSection(context, screen) + + preferenceScreen = screen + } + + private fun buildWebSection(context: Context?, screen: PreferenceScreen){ + val webCategory = PreferenceCategory(context) + webCategory.key = "web_category" + webCategory.title = getString(R.string.web_content) + screen.addPreference(webCategory) + + val customTabInfo = Preference(context) + customTabInfo.summary = getString(R.string.web_content_label) + webCategory.addPreference(customTabInfo) + + val useCustomTabsPreference = SwitchPreferenceCompat(context) + useCustomTabsPreference.setDefaultValue(true) + useCustomTabsPreference.key = Buran.PREF_KEY_USE_CUSTOM_TAB + useCustomTabsPreference.title = getString(R.string.web_content_switch_label) + webCategory.addPreference(useCustomTabsPreference) + + } + + private fun buildThemeSection(context: Context?, appCategory: PreferenceCategory) { + val themeCategory = PreferenceCategory(context) + themeCategory.key = "theme_category" + themeCategory.title = getString(R.string.theme) + appCategory.addPreference(themeCategory) + + val themeFollowSystemPreference = SwitchPreferenceCompat(context) + themeFollowSystemPreference.key = "theme_FollowSystem" + themeFollowSystemPreference.title = getString(R.string.system_default) + themeFollowSystemPreference.onPreferenceChangeListener = this + themeCategory.addPreference(themeFollowSystemPreference) + + val themeLightPreference = SwitchPreferenceCompat(context) + themeLightPreference.key = "theme_Light" + themeLightPreference.title = getString(R.string.light) + themeLightPreference.onPreferenceChangeListener = this + themeCategory.addPreference(themeLightPreference) + + val themeDarkPreference = SwitchPreferenceCompat(context) + themeDarkPreference.key = "theme_Dark" + themeDarkPreference.title = getString(R.string.dark) + themeDarkPreference.onPreferenceChangeListener = this + themeCategory.addPreference(themeDarkPreference) + + + val isThemePrefSet = + prefs.getBoolean("theme_FollowSystem", false) || + prefs.getBoolean("theme_Light", false) || + prefs.getBoolean("theme_Dark", false) + if (!isThemePrefSet) themeFollowSystemPreference.isChecked = true + + val coloursCSV = resources.openRawResource(R.raw.colours).bufferedReader().use { it.readLines() } + + val labels = mutableListOf() + val values = mutableListOf() + + coloursCSV.forEach{ line -> + val colour = line.split(",") + labels.add(colour[0]) + values.add(colour[1]) + } + + val backgroundColourPreference = ListPreference(context) + backgroundColourPreference.key = "background_colour"; + backgroundColourPreference.setDialogTitle(R.string.prefs_override_page_background_dialog_title) + backgroundColourPreference.setTitle(R.string.prefs_override_page_background_title) + backgroundColourPreference.setSummary(R.string.prefs_override_page_background) + backgroundColourPreference.setDefaultValue("#XXXXXX") + backgroundColourPreference.entries = labels.toTypedArray() + backgroundColourPreference.entryValues = values.toTypedArray() + + backgroundColourPreference.setOnPreferenceChangeListener { _, colour -> + when (colour) { + "#XXXXXX" -> this.view?.background = null + else -> this.view?.background = ColorDrawable(Color.parseColor("$colour")) + } + + true + } + + themeCategory.addPreference(backgroundColourPreference) + } + + private fun buildsAccessibility(context: Context?, screen: PreferenceScreen){ + val accessibilityCategory = PreferenceCategory(context) + accessibilityCategory.key = "accessibility_category" + accessibilityCategory.title = getString(R.string.accessibility) + screen.addPreference(accessibilityCategory) + + //Accessibility - code blocks + val aboutCodeBlocksPref = Preference(context) + aboutCodeBlocksPref.key = "unused_accessibility_pref" + aboutCodeBlocksPref.summary = getString(R.string.collapse_code_blocks_about) + aboutCodeBlocksPref.isPersistent = false + aboutCodeBlocksPref.isSelectable = false + accessibilityCategory.addPreference(aboutCodeBlocksPref) + + val collapseCodeBlocksPreference = SwitchPreferenceCompat(context) + collapseCodeBlocksPreference.key = "collapse_code_blocks" + collapseCodeBlocksPreference.title = getString(R.string.collapse_code_blocks) + accessibilityCategory.addPreference(collapseCodeBlocksPreference) + + //Accessibility - large text and buttons + val largeGemtextPreference = SwitchPreferenceCompat(context) + largeGemtextPreference.key = "use_large_gemtext_adapter" + largeGemtextPreference.title = getString(R.string.large_gemtext_and_button) + accessibilityCategory.addPreference(largeGemtextPreference) + + //Accessibility - inline icons + val showInlineIconsPreference = SwitchPreferenceCompat(context) + showInlineIconsPreference.key = "show_inline_icons" + showInlineIconsPreference.title = getString(R.string.show_inline_icons) + accessibilityCategory.addPreference(showInlineIconsPreference) + } + + private fun buildTLSSection(context: Context?, screen: PreferenceScreen) { + val tlsCategory = PreferenceCategory(context) + tlsCategory.key = "tls_category" + tlsCategory.title = getString(R.string.tls_config) + screen.addPreference(tlsCategory) + + val tlsDefaultPreference = SwitchPreferenceCompat(context) + tlsDefaultPreference.key = "tls_Default" + tlsDefaultPreference.title = getString(R.string.tls_default) + tlsDefaultPreference.onPreferenceChangeListener = this + tlsCategory.addPreference(tlsDefaultPreference) + + //This feel inelegant: + var tlsPrefSet = false + prefs.all.forEach { pref -> + if (pref.key.startsWith("tls_")) tlsPrefSet = true + } + + if (!tlsPrefSet) { + tlsDefaultPreference.isChecked = true + } + + val tlsAllSupportedPreference = SwitchPreferenceCompat(context) + tlsAllSupportedPreference.key = "tls_All_Supported" + tlsAllSupportedPreference.title = getString(R.string.tls_enable_all_supported) + tlsAllSupportedPreference.onPreferenceChangeListener = this + tlsCategory.addPreference(tlsAllSupportedPreference) + + val sslContext = SSLContext.getInstance("TLS") + sslContext.init(null, null, SecureRandom()) + val factory: SSLSocketFactory = sslContext.socketFactory + val socket = factory.createSocket() as SSLSocket + protocols = socket.supportedProtocols + protocols.forEach { protocol -> + val tlsPreference = SwitchPreferenceCompat(context) + tlsPreference.key = "tls_${protocol.toLowerCase(Locale.getDefault())}" + tlsPreference.title = protocol + tlsPreference.onPreferenceChangeListener = this + tlsCategory.addPreference(tlsPreference) + } + } + + private fun buildClientCertificateSection(context: Context?, appCategory: PreferenceCategory) { + if (Buran.FEATURE_CLIENT_CERTS) { + + val aboutPref = Preference(context) + aboutPref.key = "unused_pref" + aboutPref.summary = getString(R.string.pkcs_notice) + aboutPref.isPersistent = false + aboutPref.isSelectable = false + appCategory.addPreference(aboutPref) + + clientCertPref = Preference(context) + clientCertPref.title = getString(R.string.client_certificate) + clientCertPref.key = Buran.PREF_KEY_CLIENT_CERT_HUMAN_READABLE + + val clientCertUriHumanReadable = preferenceManager.sharedPreferences.getString( + Buran.PREF_KEY_CLIENT_CERT_HUMAN_READABLE, + null + ) + + val hasCert = clientCertUriHumanReadable != null + if (!hasCert) { + clientCertPref.summary = getString(R.string.tap_to_select_client_certificate) + } else { + clientCertPref.summary = clientCertUriHumanReadable + } + + clientCertPref.setOnPreferenceClickListener { + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + type = "*/*" + } + startActivityForResult(intent, PREFS_SET_CLIENT_CERT_REQ) + true + } + + appCategory.addPreference(clientCertPref) + + + val clientCertPassword = EditTextPreference(context) + clientCertPassword.key = Buran.PREF_KEY_CLIENT_CERT_PASSWORD + clientCertPassword.title = getString(R.string.client_certificate_password) + + val certPasword = preferenceManager.sharedPreferences.getString( + Buran.PREF_KEY_CLIENT_CERT_PASSWORD, + null + ) + if (certPasword != null && certPasword.isNotEmpty()) { + clientCertPassword.summary = getDots(certPasword) + } else { + clientCertPassword.summary = getString(R.string.no_password) + } + clientCertPassword.dialogTitle = getString(R.string.client_certificate_password) + clientCertPassword.setOnPreferenceChangeListener { _, newValue -> + val passphrase = "$newValue" + if (passphrase.isEmpty()) { + clientCertPassword.summary = getString(R.string.no_password) + } else { + clientCertPassword.summary = getDots(passphrase) + } + + true//update the value + } + + appCategory.addPreference(clientCertPassword) + + useClientCertPreference = SwitchPreferenceCompat(context) + useClientCertPreference.key = Buran.PREF_KEY_CLIENT_CERT_ACTIVE + useClientCertPreference.title = getString(R.string.use_client_certificate) + appCategory.addPreference(useClientCertPreference) + + if (!hasCert) { + useClientCertPreference.isVisible = false + } + } + } + + private fun getDots(value: String): String { + val sb = StringBuilder() + repeat(value.length){ + sb.append("•") + } + return sb.toString() + } + + override fun onPreferenceChange(preference: Preference?, newValue: Any?): Boolean { + if(preference == null) return false + + if(preference.key.startsWith("tls")){ + tlsChangeListener(preference, newValue) + return true + } + + if(preference.key.startsWith("theme")){ + when(preference.key){ + "theme_FollowSystem" -> { + preferenceScreen.findPreference("theme_Light")?.isChecked = + false + preferenceScreen.findPreference("theme_Dark")?.isChecked = + false + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) + } + "theme_Light" -> { + preferenceScreen.findPreference("theme_FollowSystem")?.isChecked = + false + preferenceScreen.findPreference("theme_Dark")?.isChecked = + false + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) + } + "theme_Dark" -> { + preferenceScreen.findPreference("theme_FollowSystem")?.isChecked = + false + preferenceScreen.findPreference("theme_Light")?.isChecked = + false + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) + } + } + return true + } + return false + } + + private fun tlsChangeListener( + preference: Preference?, newValue: Any? + ) { + if (preference is SwitchPreferenceCompat && newValue is Boolean && newValue == true) { + preference.key?.let { key -> + when { + key.startsWith("tls_") -> { + if (key != "tls_Default") { + val default = preferenceScreen.findPreference("tls_Default") + default?.isChecked = false + } + if (key != "tls_All_Supported") { + val all = preferenceScreen.findPreference("tls_All_Supported") + all?.isChecked = false + } + protocols.forEach { protocol -> + val tlsSwitchKey = "tls_${protocol.toLowerCase(Locale.getDefault())}" + if (tlsSwitchKey != key) { + val otherTLSSwitch = + preferenceScreen.findPreference( + tlsSwitchKey + ) + otherTLSSwitch?.isChecked = false + } + } + } + } + } + + when (preference.key) { + "tls_Default" -> setTLSProtocol("TLS") + "tls_All_Supported" -> setTLSProtocol("TLS_ALL") + else -> { + val prefTitle = preference.title.toString() + setTLSProtocol(prefTitle) + } + } + } + } + + private fun setTLSProtocol(protocol: String) = preferenceManager.sharedPreferences.edit().putString( + "tls_protocol", + protocol + ).apply() + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if(requestCode == PREFS_SET_CLIENT_CERT_REQ && resultCode == RESULT_OK){ + data?.data?.also { uri -> + preferenceManager.sharedPreferences.edit().putString( + Buran.PREF_KEY_CLIENT_CERT_URI, + uri.toString() + ).apply() + persistPermissions(uri) + findFilename(uri) + } + + } + super.onActivityResult(requestCode, resultCode, data) + } + + private fun persistPermissions(uri: Uri) { + val contentResolver = requireContext().contentResolver + + val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + contentResolver.takePersistableUriPermission(uri, takeFlags) + } + + private fun findFilename(uri: Uri) { + + var readableReference = uri.toString() + if (uri.scheme == "content") { + requireContext().contentResolver.query(uri, null, null, null, null).use { cursor -> + if (cursor != null && cursor.moveToFirst()) { + readableReference = cursor.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)) + } + } + } + + preferenceManager.sharedPreferences.edit().putString( + Buran.PREF_KEY_CLIENT_CERT_HUMAN_READABLE, + readableReference + ).apply() + clientCertPref.summary = readableReference + useClientCertPreference.isChecked = true + } +} diff --git a/app/src/main/res/anim/fade_in.xml b/app/src/main/res/anim/fade_in.xml new file mode 100644 index 0000000..3b0c30e --- /dev/null +++ b/app/src/main/res/anim/fade_in.xml @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/app/src/main/res/drawable-anydpi-v26/laucher_round.xml b/app/src/main/res/drawable-anydpi-v26/laucher_round.xml new file mode 100644 index 0000000..4afa145 --- /dev/null +++ b/app/src/main/res/drawable-anydpi-v26/laucher_round.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-anydpi-v26/launcher.xml b/app/src/main/res/drawable-anydpi-v26/launcher.xml new file mode 100644 index 0000000..4afa145 --- /dev/null +++ b/app/src/main/res/drawable-anydpi-v26/launcher.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/block_background.xml b/app/src/main/res/drawable/block_background.xml new file mode 100644 index 0000000..e4116ae --- /dev/null +++ b/app/src/main/res/drawable/block_background.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/drawable_filled_rounded_rect.xml b/app/src/main/res/drawable/drawable_filled_rounded_rect.xml new file mode 100644 index 0000000..89a739d --- /dev/null +++ b/app/src/main/res/drawable/drawable_filled_rounded_rect.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/drawable_stroke_rounded_rect.xml b/app/src/main/res/drawable/drawable_stroke_rounded_rect.xml new file mode 100644 index 0000000..b4a4e62 --- /dev/null +++ b/app/src/main/res/drawable/drawable_stroke_rounded_rect.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/launcher.xml b/app/src/main/res/drawable/launcher.xml new file mode 100644 index 0000000..4afa145 --- /dev/null +++ b/app/src/main/res/drawable/launcher.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/launcher_round.xml b/app/src/main/res/drawable/launcher_round.xml new file mode 100644 index 0000000..4afa145 --- /dev/null +++ b/app/src/main/res/drawable/launcher_round.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/vector_app_icon.xml b/app/src/main/res/drawable/vector_app_icon.xml new file mode 100644 index 0000000..198b857 --- /dev/null +++ b/app/src/main/res/drawable/vector_app_icon.xml @@ -0,0 +1,161 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/vector_cancel.xml b/app/src/main/res/drawable/vector_cancel.xml new file mode 100644 index 0000000..c3bf1f3 --- /dev/null +++ b/app/src/main/res/drawable/vector_cancel.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/vector_client_cert.xml b/app/src/main/res/drawable/vector_client_cert.xml new file mode 100644 index 0000000..29f1242 --- /dev/null +++ b/app/src/main/res/drawable/vector_client_cert.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/vector_close.xml b/app/src/main/res/drawable/vector_close.xml new file mode 100644 index 0000000..eb50bb9 --- /dev/null +++ b/app/src/main/res/drawable/vector_close.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/vector_code.xml b/app/src/main/res/drawable/vector_code.xml new file mode 100644 index 0000000..03fb882 --- /dev/null +++ b/app/src/main/res/drawable/vector_code.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/vector_confirm.xml b/app/src/main/res/drawable/vector_confirm.xml new file mode 100644 index 0000000..573a991 --- /dev/null +++ b/app/src/main/res/drawable/vector_confirm.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/vector_home.xml b/app/src/main/res/drawable/vector_home.xml new file mode 100644 index 0000000..90adef6 --- /dev/null +++ b/app/src/main/res/drawable/vector_home.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/vector_link.xml b/app/src/main/res/drawable/vector_link.xml new file mode 100644 index 0000000..76ce749 --- /dev/null +++ b/app/src/main/res/drawable/vector_link.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/vector_open_browser.xml b/app/src/main/res/drawable/vector_open_browser.xml new file mode 100644 index 0000000..b53a239 --- /dev/null +++ b/app/src/main/res/drawable/vector_open_browser.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/vector_overflow.xml b/app/src/main/res/drawable/vector_overflow.xml new file mode 100644 index 0000000..e4130c4 --- /dev/null +++ b/app/src/main/res/drawable/vector_overflow.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/vector_photo.xml b/app/src/main/res/drawable/vector_photo.xml new file mode 100644 index 0000000..8232c4d --- /dev/null +++ b/app/src/main/res/drawable/vector_photo.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/vector_refresh.xml b/app/src/main/res/drawable/vector_refresh.xml new file mode 100644 index 0000000..254e1cd --- /dev/null +++ b/app/src/main/res/drawable/vector_refresh.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/vector_save.xml b/app/src/main/res/drawable/vector_save.xml new file mode 100644 index 0000000..2a6cd4c --- /dev/null +++ b/app/src/main/res/drawable/vector_save.xml @@ -0,0 +1,13 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/font/code_font.xml b/app/src/main/res/font/code_font.xml new file mode 100644 index 0000000..5758698 --- /dev/null +++ b/app/src/main/res/font/code_font.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/font/jet_brains_mono.ttf b/app/src/main/res/font/jet_brains_mono.ttf new file mode 100644 index 0000000000000000000000000000000000000000..9a5202eb14992dccefd8bc78d07ddaf2477a441c GIT binary patch literal 114320 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 MmYJ~p6B5Dy1vl^>7XSbN literal 0 HcmV?d00001 diff --git a/app/src/main/res/layout/activity_gem.xml b/app/src/main/res/layout/activity_gem.xml new file mode 100644 index 0000000..f716be6 --- /dev/null +++ b/app/src/main/res/layout/activity_gem.xml @@ -0,0 +1,125 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml new file mode 100644 index 0000000..a3a0919 --- /dev/null +++ b/app/src/main/res/layout/activity_settings.xml @@ -0,0 +1,20 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/bookmark.xml b/app/src/main/res/layout/bookmark.xml new file mode 100644 index 0000000..bcf8498 --- /dev/null +++ b/app/src/main/res/layout/bookmark.xml @@ -0,0 +1,39 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_about.xml b/app/src/main/res/layout/dialog_about.xml new file mode 100644 index 0000000..3ee388a --- /dev/null +++ b/app/src/main/res/layout/dialog_about.xml @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_bookmarks.xml b/app/src/main/res/layout/dialog_bookmarks.xml new file mode 100644 index 0000000..af2e5a4 --- /dev/null +++ b/app/src/main/res/layout/dialog_bookmarks.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_content_image.xml b/app/src/main/res/layout/dialog_content_image.xml new file mode 100644 index 0000000..d2add3c --- /dev/null +++ b/app/src/main/res/layout/dialog_content_image.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_content_text.xml b/app/src/main/res/layout/dialog_content_text.xml new file mode 100644 index 0000000..fac2245 --- /dev/null +++ b/app/src/main/res/layout/dialog_content_text.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_history.xml b/app/src/main/res/layout/dialog_history.xml new file mode 100644 index 0000000..a22b3da --- /dev/null +++ b/app/src/main/res/layout/dialog_history.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_input_query.xml b/app/src/main/res/layout/dialog_input_query.xml new file mode 100644 index 0000000..9f190fa --- /dev/null +++ b/app/src/main/res/layout/dialog_input_query.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_set_home.xml b/app/src/main/res/layout/dialog_set_home.xml new file mode 100644 index 0000000..7d88f0d --- /dev/null +++ b/app/src/main/res/layout/dialog_set_home.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_tabs.xml b/app/src/main/res/layout/dialog_tabs.xml new file mode 100644 index 0000000..2bb7069 --- /dev/null +++ b/app/src/main/res/layout/dialog_tabs.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_bookmark_dialog.xml b/app/src/main/res/layout/fragment_bookmark_dialog.xml new file mode 100644 index 0000000..d9530e4 --- /dev/null +++ b/app/src/main/res/layout/fragment_bookmark_dialog.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/gemtext_code_block.xml b/app/src/main/res/layout/gemtext_code_block.xml new file mode 100644 index 0000000..f56f05f --- /dev/null +++ b/app/src/main/res/layout/gemtext_code_block.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/gemtext_h1.xml b/app/src/main/res/layout/gemtext_h1.xml new file mode 100644 index 0000000..eab5160 --- /dev/null +++ b/app/src/main/res/layout/gemtext_h1.xml @@ -0,0 +1,14 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/gemtext_h2.xml b/app/src/main/res/layout/gemtext_h2.xml new file mode 100644 index 0000000..25f3a77 --- /dev/null +++ b/app/src/main/res/layout/gemtext_h2.xml @@ -0,0 +1,14 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/gemtext_h3.xml b/app/src/main/res/layout/gemtext_h3.xml new file mode 100644 index 0000000..d31bb90 --- /dev/null +++ b/app/src/main/res/layout/gemtext_h3.xml @@ -0,0 +1,14 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/gemtext_image_link.xml b/app/src/main/res/layout/gemtext_image_link.xml new file mode 100644 index 0000000..e45ef88 --- /dev/null +++ b/app/src/main/res/layout/gemtext_image_link.xml @@ -0,0 +1,34 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/gemtext_large_code_block.xml b/app/src/main/res/layout/gemtext_large_code_block.xml new file mode 100644 index 0000000..64cdbe0 --- /dev/null +++ b/app/src/main/res/layout/gemtext_large_code_block.xml @@ -0,0 +1,58 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/gemtext_large_h1.xml b/app/src/main/res/layout/gemtext_large_h1.xml new file mode 100644 index 0000000..2a7ffd4 --- /dev/null +++ b/app/src/main/res/layout/gemtext_large_h1.xml @@ -0,0 +1,14 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/gemtext_large_h2.xml b/app/src/main/res/layout/gemtext_large_h2.xml new file mode 100644 index 0000000..79129ad --- /dev/null +++ b/app/src/main/res/layout/gemtext_large_h2.xml @@ -0,0 +1,14 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/gemtext_large_h3.xml b/app/src/main/res/layout/gemtext_large_h3.xml new file mode 100644 index 0000000..a058081 --- /dev/null +++ b/app/src/main/res/layout/gemtext_large_h3.xml @@ -0,0 +1,14 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/gemtext_large_image_link.xml b/app/src/main/res/layout/gemtext_large_image_link.xml new file mode 100644 index 0000000..1034652 --- /dev/null +++ b/app/src/main/res/layout/gemtext_large_image_link.xml @@ -0,0 +1,37 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/gemtext_large_link.xml b/app/src/main/res/layout/gemtext_large_link.xml new file mode 100644 index 0000000..bfdbadb --- /dev/null +++ b/app/src/main/res/layout/gemtext_large_link.xml @@ -0,0 +1,26 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/gemtext_large_quote.xml b/app/src/main/res/layout/gemtext_large_quote.xml new file mode 100644 index 0000000..72ac4c3 --- /dev/null +++ b/app/src/main/res/layout/gemtext_large_quote.xml @@ -0,0 +1,18 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/gemtext_large_text.xml b/app/src/main/res/layout/gemtext_large_text.xml new file mode 100644 index 0000000..ab06559 --- /dev/null +++ b/app/src/main/res/layout/gemtext_large_text.xml @@ -0,0 +1,12 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/gemtext_link.xml b/app/src/main/res/layout/gemtext_link.xml new file mode 100644 index 0000000..ae90351 --- /dev/null +++ b/app/src/main/res/layout/gemtext_link.xml @@ -0,0 +1,23 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/gemtext_quote.xml b/app/src/main/res/layout/gemtext_quote.xml new file mode 100644 index 0000000..b09364a --- /dev/null +++ b/app/src/main/res/layout/gemtext_quote.xml @@ -0,0 +1,18 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/gemtext_text.xml b/app/src/main/res/layout/gemtext_text.xml new file mode 100644 index 0000000..cae3ef9 --- /dev/null +++ b/app/src/main/res/layout/gemtext_text.xml @@ -0,0 +1,12 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/row_history.xml b/app/src/main/res/layout/row_history.xml new file mode 100644 index 0000000..6689149 --- /dev/null +++ b/app/src/main/res/layout/row_history.xml @@ -0,0 +1,20 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/add_bookmark.xml b/app/src/main/res/menu/add_bookmark.xml new file mode 100644 index 0000000..f748e4d --- /dev/null +++ b/app/src/main/res/menu/add_bookmark.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/add_bookmarks.xml b/app/src/main/res/menu/add_bookmarks.xml new file mode 100644 index 0000000..fe187c0 --- /dev/null +++ b/app/src/main/res/menu/add_bookmarks.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/audio_overflow.xml b/app/src/main/res/menu/audio_overflow.xml new file mode 100644 index 0000000..9170b86 --- /dev/null +++ b/app/src/main/res/menu/audio_overflow.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/bookmark_import_export.xml b/app/src/main/res/menu/bookmark_import_export.xml new file mode 100644 index 0000000..99c8216 --- /dev/null +++ b/app/src/main/res/menu/bookmark_import_export.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/history_overflow_menu.xml b/app/src/main/res/menu/history_overflow_menu.xml new file mode 100644 index 0000000..861457d --- /dev/null +++ b/app/src/main/res/menu/history_overflow_menu.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/image_link_menu.xml b/app/src/main/res/menu/image_link_menu.xml new file mode 100644 index 0000000..099518b --- /dev/null +++ b/app/src/main/res/menu/image_link_menu.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/app/src/main/res/menu/image_overflow_menu.xml b/app/src/main/res/menu/image_overflow_menu.xml new file mode 100644 index 0000000..6efd056 --- /dev/null +++ b/app/src/main/res/menu/image_overflow_menu.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/link_menu.xml b/app/src/main/res/menu/link_menu.xml new file mode 100644 index 0000000..d3846b8 --- /dev/null +++ b/app/src/main/res/menu/link_menu.xml @@ -0,0 +1,5 @@ + + + + diff --git a/app/src/main/res/menu/menu_bookmark.xml b/app/src/main/res/menu/menu_bookmark.xml new file mode 100644 index 0000000..53a093e --- /dev/null +++ b/app/src/main/res/menu/menu_bookmark.xml @@ -0,0 +1,11 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/overflow_menu.xml b/app/src/main/res/menu/overflow_menu.xml new file mode 100644 index 0000000..d26dcbf --- /dev/null +++ b/app/src/main/res/menu/overflow_menu.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/raw/cert.pfx b/app/src/main/res/raw/cert.pfx new file mode 100644 index 0000000000000000000000000000000000000000..142dbb03aa0a6cb6a0790ee47e1f9685efe215e0 GIT binary patch literal 3941 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/raw/colours.csv b/app/src/main/res/raw/colours.csv new file mode 100644 index 0000000..a26d903 --- /dev/null +++ b/app/src/main/res/raw/colours.csv @@ -0,0 +1,147 @@ +Default,#XXXXXX +Alice Blue,#F0F8FF +Antique White,#FAEBD7 +Aqua,#00FFFF +Aquamarine,#7FFFD4 +Azure,#F0FFFF +Beige,#F5F5DC +Bisque,#FFE4C4 +Black,#000000 +Blanched Almond,#FFEBCD +Blue,#0000FF +Blue Violet,#8A2BE2 +Brown,#A52A2A +Burly Wood,#DEB887 +Cadet Blue,#5F9EA0 +Chartreuse,#7FFF00 +Chocolate,#D2691E +Coral,#FF7F50 +Cornflower Blue,#6495ED +Cornsilk,#FFF8DC +Crimson,#DC143C +Cyan,#00FFFF +Dark Blue,#00008B +Dark Cyan,#008B8B +Dark Golden Rod,#B8860B +Dark Gray,#A9A9A9 +Dark Green,#006400 +Dark Khaki,#BDB76B +Dark Magenta,#8B008B +Dark Olive Green,#556B2F +Dark Orange,#FF8C00 +Dark Orchid,#9932CC +Dark Red,#8B0000 +Dark Salmon,#E9967A +Dark Sea Green,#8FBC8F +Dark Slate Blue,#483D8B +Dark Slate Gray,#2F4F4F +Dark Slate Grey,#2F4F4F +Dark Turquoise,#00CED1 +Dark Violet,#9400D3 +Deep Pink,#FF1493 +Deep Sky Blue,#00BFFF +Dim Gray,#696969 +Dim Grey,#696969 +Dodger Blue,#1E90FF +Fire Brick,#B22222 +Floral White,#FFFAF0 +Forest Green,#228B22 +Fuchsia,#FF00FF +Gainsboro,#DCDCDC +Ghost White,#F8F8FF +Gold,#FFD700 +Golden Rod,#DAA520 +Gray,#808080 +Grey,#808080 +Green,#008000 +Green Yellow,#ADFF2F +Honey Dew,#F0FFF0 +Hot Pink,#FF69B4 +Indian Red ,#CD5C5C +Indigo ,#4B0082 +Ivory,#FFFFF0 +Khaki,#F0E68C +Lavender,#E6E6FA +Lavender Blush,#FFF0F5 +Lawn Green,#7CFC00 +Lemon Chiffon,#FFFACD +Light Blue,#ADD8E6 +Light Coral,#F08080 +Light Cyan,#E0FFFF +Light Golden Rod Yellow,#FAFAD2 +Light Gray,#D3D3D3 +Light Grey,#D3D3D3 +Light Green,#90EE90 +Light Pink,#FFB6C1 +Light Salmon,#FFA07A +Light Sea Green,#20B2AA +Light Sky Blue,#87CEFA +Light Slate Gray,#778899 +Light Slate Grey,#778899 +Light Steel Blue,#B0C4DE +Light Yellow,#FFFFE0 +Lime,#00FF00 +Lime Green,#32CD32 +Linen,#FAF0E6 +Magenta,#FF00FF +Maroon,#800000 +Medium Aqua Marine,#66CDAA +Medium Blue,#0000CD +Medium Orchid,#BA55D3 +Medium Purple,#9370D8 +Medium Sea Green,#3CB371 +Medium Slate Blue,#7B68EE +Medium Spring Green,#00FA9A +Medium Turquoise,#48D1CC +Medium Violet Red,#C71585 +Midnight Blue,#191970 +Mint Cream,#F5FFFA +Misty Rose,#FFE4E1 +Moccasin,#FFE4B5 +Navajo White,#FFDEAD +Navy,#000080 +Old Lace,#FDF5E6 +Olive,#808000 +Olive Drab,#6B8E23 +Orange,#FFA500 +Orange Red,#FF4500 +Orchid,#DA70D6 +Pale Golden Rod,#EEE8AA +Pale Green,#98FB98 +Pale Turquoise,#AFEEEE +Pale Violet Red,#D87093 +Papaya Whip,#FFEFD5 +Peach Puff,#FFDAB9 +Peru,#CD853F +Pink,#FFC0CB +Plum,#DDA0DD +Powder Blue,#B0E0E6 +Purple,#800080 +Red,#FF0000 +Rosy Brown,#BC8F8F +Royal Blue,#4169E1 +Saddle Brown,#8B4513 +Salmon,#FA8072 +Sandy Brown,#F4A460 +Sea Green,#2E8B57 +Sea Shell,#FFF5EE +Sienna,#A0522D +Silver,#C0C0C0 +Sky Blue,#87CEEB +Slate Blue,#6A5ACD +Slate Gray,#708090 +Slate Grey,#708090 +Snow,#FFFAFA +Spring Green,#00FF7F +Steel Blue,#4682B4 +Tan,#D2B48C +Teal,#008080 +Thistle,#D8BFD8 +Tomato,#FF6347 +Turquoise,#40E0D0 +Violet,#EE82EE +Wheat,#F5DEB3 +White,#FFFFFF +White Smoke,#F5F5F5 +Yellow,#FFFF00 +Yellow Green,#9ACD32 \ No newline at end of file diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml new file mode 100644 index 0000000..9bb7488 --- /dev/null +++ b/app/src/main/res/values-fr/strings.xml @@ -0,0 +1,80 @@ + + Buran + gemini:// + Entrez l\'adresse gemini:// + Entrez un terme de recherche + Partager l\'adresse + Afficher en ligne + À propos + Adresse copiée dans le presse-papiers + Adresse Gemini + Partager + Choisir comme Accueil + Paramètres + Buran: Un client pour le protocole Gemini par Corewala + Copyright © 2021 Corewala + Buran est basé sur le navigateur Ariane d\'ÖLAB sous la Licence Publique de l\'Union Européenne + Les blocs de code sont rendus avec JetBrains Mono de JetBrains + Les glyphes utilisés proviennent de Material Icons par Google + Vider le cache d\'exécution + Historique + Vider l\'historique + Rafraîchir + Soumettre + Rechercher + Sauvegarder l\'image + Sauvegarder la piste + Cacher le lecteur + Liens de retour + Ajouter marque-page + Marque-pages + Nom + URL Gemini + Éditer + Supprimer + Déplacer vers le bas + Déplacer vers le haut + Type Mime inconnu + Hôte inconnu + Télécharger + Annuler + Erreur + Aucune app installée qui puisse ouvrir %s + Erreur de téléchargement de fichier - aucun état d\'objet n\'existe + Fichier sauvegardé dans l\'appareil + Configurer Buran + Capsule d\'accueil + Mettre à jour + Thème + Paramètre système + Clair + Sombre + Config TLS + Contenu Web + Ouvrir les sites web en interne en utilisant des \'Onglets Personnalisés\', plutôt que d\'utiliser le navigateur par défaut. Cela pourrait vous aider à rester dans le Geminispace plutôt que d\'être distrait·e par le vaste web. Cela requiert un navigateur par défaut compatible. + Ouvrir en interne + TLS par défaut + Activer toutes les versions TLS supportées + Seuls les magasins de clés client PKCS12 sont actuellement supportés + Certificat Client + Cliquez pour sélectionner un certificat client + Mot de passe du Certificat Client + Pas de mot de passe + Utiliser un Certificat Client + Choisir comme capsule d\'accueil + Historique vidé + Cache d\'exécution vidé + Icônes de lien en ligne + Vous n\'avez encore aucun marque-pages + Importer des marque-pages + Exporter des marque-pages + Accessibilité + Cacher les rectangles pleins + Montrer le code + Cacher le code + Gemtexte large + Les capsules Gemini utilisent malheureusement souvent des en-têtes en ascii-art rendus avec des rectangles pleins à largeur fixe. Quand les rectangles pleins sont cachés, ils nécessitent un clic pour être affichés, ce qui améliore l\'ergonomie en cas d\'utilisation d\'un lecteur d\'écran. + Utiliser une couleur d\'arrière-plan personnalisée + Couleur d\'arrière-plan + Couleur d\'arrière-plan + \ No newline at end of file diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml new file mode 100644 index 0000000..333815d --- /dev/null +++ b/app/src/main/res/values-night/colors.xml @@ -0,0 +1,17 @@ + + + #ffffff + #1d1d1d + #F65E5E + #000000 + #1d1d1d + #ffdede + #2e2e2e + + #ffffff + #ffffff + #1d1d1d + #d2d2d2 + + #3A3A3A + \ No newline at end of file diff --git a/app/src/main/res/values-night/styles.xml b/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..5e1673d --- /dev/null +++ b/app/src/main/res/values-night/styles.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + +