commit b80bc9948ab77c0efe667aa0e27e18f0e87ebe20 Author: Corewala Date: Thu Dec 2 13:11:34 2021 -0500 Initial commit 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 0000000..9a5202e Binary files /dev/null and b/app/src/main/res/font/jet_brains_mono.ttf differ 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 0000000..142dbb0 Binary files /dev/null and b/app/src/main/res/raw/cert.pfx differ 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 @@ + + + + + + + + + + + + + + +