mirror of https://github.com/Corewala/Buran
Initial commit
This commit is contained in:
commit
b80bc9948a
|
@ -0,0 +1,4 @@
|
||||||
|
.gradle
|
||||||
|
.idea
|
||||||
|
build
|
||||||
|
release
|
|
@ -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.
|
|
@ -0,0 +1,20 @@
|
||||||
|
# Buran
|
||||||
|
|
||||||
|
<img width="180" src="buran.svg" />
|
||||||
|
|
||||||
|
[![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.
|
|
@ -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
|
|
@ -0,0 +1,59 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
package="corewala.buran">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name="corewala.buran.Buran"
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@drawable/launcher"
|
||||||
|
android:label="Buran"
|
||||||
|
android:roundIcon="@drawable/launcher"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/AppTheme"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config">
|
||||||
|
<activity
|
||||||
|
android:name="corewala.buran.ui.ProcessTextActivity"
|
||||||
|
android:label="Open in Buran"
|
||||||
|
android:launchMode="singleTop">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.PROCESS_TEXT" />
|
||||||
|
<data android:mimeType="text/plain"/>
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
<activity
|
||||||
|
android:name="corewala.buran.ui.GemActivity"
|
||||||
|
android:launchMode="singleTop">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="gemini" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name="corewala.buran.ui.settings.SettingsActivity"
|
||||||
|
android:label="@string/settings"
|
||||||
|
android:theme="@style/SettingsTheme"/>
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.provider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/provider_paths" />
|
||||||
|
</provider>
|
||||||
|
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
|
@ -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)
|
||||||
|
}
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<OppenURI>()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<String>) : 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()
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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<BookmarkEntity>
|
||||||
|
|
||||||
|
@Query("SELECT * from bookmarks WHERE uiIndex = :index LIMIT 1")
|
||||||
|
suspend fun getBookmark(index: Int): BookmarkEntity
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertAll(bookmarks: Array<BookmarkEntity>)
|
||||||
|
|
||||||
|
@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)
|
||||||
|
}
|
|
@ -0,0 +1,13 @@
|
||||||
|
package corewala.buran.io.database.bookmarks
|
||||||
|
|
||||||
|
interface BookmarksDatasource {
|
||||||
|
|
||||||
|
fun get(onBookmarks: (List<BookmarkEntry>) -> Unit)
|
||||||
|
fun add(bookmarkEntry: BookmarkEntry, onAdded: () -> Unit)
|
||||||
|
fun add(bookmarkEntries: Array<BookmarkEntry>, 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)
|
||||||
|
}
|
|
@ -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<BookmarkEntry>) -> Unit) {
|
||||||
|
GlobalScope.launch(Dispatchers.IO){
|
||||||
|
val dbBookmarks = db.bookmarks().getAll()
|
||||||
|
val bookmarks = mutableListOf<BookmarkEntry>()
|
||||||
|
|
||||||
|
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<BookmarkEntry>, 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<HistoryEntry>) -> Unit) {
|
||||||
|
GlobalScope.launch(Dispatchers.IO){
|
||||||
|
val dbBookmarks = db.history().getAll()
|
||||||
|
val history = mutableListOf<HistoryEntry>()
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<HistoryEntity>
|
||||||
|
|
||||||
|
@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()
|
||||||
|
}
|
|
@ -0,0 +1,12 @@
|
||||||
|
package corewala.buran.io.database.history
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
|
||||||
|
interface HistoryDatasource {
|
||||||
|
|
||||||
|
fun get(onHistory: (List<HistoryEntry>) -> Unit)
|
||||||
|
fun add(entry: HistoryEntry, onAdded: () -> Unit)
|
||||||
|
fun add(uri: Uri, onAdded: () -> Unit)
|
||||||
|
fun clear(onClear: () -> Unit)
|
||||||
|
fun delete(entry: HistoryEntry, onDelete: () -> Unit)
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
)
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<TrustManager> {
|
||||||
|
return arrayOf(
|
||||||
|
object : X509TrustManager {
|
||||||
|
override fun checkClientTrusted(
|
||||||
|
chain: Array<out X509Certificate>?,
|
||||||
|
authType: String?
|
||||||
|
) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun checkServerTrusted(
|
||||||
|
chain: Array<out X509Certificate>?,
|
||||||
|
authType: String?
|
||||||
|
) {
|
||||||
|
println("checkServerTrusted()")
|
||||||
|
println("checkServerTrusted() authType: $authType")
|
||||||
|
chain?.forEach { cert ->
|
||||||
|
println("checkServerTrusted() cert: ${cert.subjectDN}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAcceptedIssuers(): Array<X509Certificate> {
|
||||||
|
return arrayOf()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<URI>()
|
||||||
|
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<String>()
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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<String>): List<String>{
|
||||||
|
val sb = StringBuilder()
|
||||||
|
var inCodeBlock = false
|
||||||
|
val parsed = mutableListOf<String>()
|
||||||
|
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</|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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<String> {
|
||||||
|
return when (val raw = prefs.getString(prefsHistoryKey, null)) {
|
||||||
|
null -> arrayListOf()
|
||||||
|
else -> ArrayList(raw.split(DELIM))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
package corewala.buran.io.history.uris
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
|
||||||
|
interface HistoryInterface {
|
||||||
|
fun add(address: String)
|
||||||
|
fun get(): List<String>
|
||||||
|
fun clear()
|
||||||
|
|
||||||
|
companion object{
|
||||||
|
fun default(context: Context): HistoryInterface {
|
||||||
|
return BasicURIHistory(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<GemViewModel>()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<BookmarksAdapter.ViewHolder>() {
|
||||||
|
|
||||||
|
val bookmarks = mutableListOf<BookmarkEntry>()
|
||||||
|
|
||||||
|
class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView)
|
||||||
|
|
||||||
|
fun update(bookmarkEntries: List<BookmarkEntry>){
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<BookmarkEntry>()
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<GmiViewHolder>() {
|
||||||
|
|
||||||
|
var showInlineIcons: Boolean = false
|
||||||
|
var hideCodeBlocks: Boolean = false
|
||||||
|
|
||||||
|
abstract fun render(lines: List<String>)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<String>()
|
||||||
|
private var inlineImages = HashMap<Int, Uri>()
|
||||||
|
|
||||||
|
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<String>){
|
||||||
|
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</|ALT>"
|
||||||
|
altText = line.substring(10, line.indexOf("</|ALT>"))
|
||||||
|
holder.itemView.gemtext_text_monospace_textview.text = line.substring(line.indexOf("</|ALT>") + 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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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<String>()
|
||||||
|
private var inlineImages = HashMap<Int, Uri>()
|
||||||
|
|
||||||
|
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<String>){
|
||||||
|
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</|ALT>"
|
||||||
|
altText = line.substring(10, line.indexOf("</|ALT>"))
|
||||||
|
holder.itemView.gemtext_text_monospace_textview.text = line.substring(line.indexOf("</|ALT>") + 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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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<HistoryEntry>, val onClick:(entry: HistoryEntry) -> Unit): RecyclerView.Adapter<HistoryAdapter.ViewHolder>() {
|
||||||
|
|
||||||
|
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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<String>
|
||||||
|
|
||||||
|
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<String>()
|
||||||
|
val values = mutableListOf<String>()
|
||||||
|
|
||||||
|
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<SwitchPreferenceCompat>("theme_Light")?.isChecked =
|
||||||
|
false
|
||||||
|
preferenceScreen.findPreference<SwitchPreferenceCompat>("theme_Dark")?.isChecked =
|
||||||
|
false
|
||||||
|
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
|
||||||
|
}
|
||||||
|
"theme_Light" -> {
|
||||||
|
preferenceScreen.findPreference<SwitchPreferenceCompat>("theme_FollowSystem")?.isChecked =
|
||||||
|
false
|
||||||
|
preferenceScreen.findPreference<SwitchPreferenceCompat>("theme_Dark")?.isChecked =
|
||||||
|
false
|
||||||
|
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
|
||||||
|
}
|
||||||
|
"theme_Dark" -> {
|
||||||
|
preferenceScreen.findPreference<SwitchPreferenceCompat>("theme_FollowSystem")?.isChecked =
|
||||||
|
false
|
||||||
|
preferenceScreen.findPreference<SwitchPreferenceCompat>("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<SwitchPreferenceCompat>("tls_Default")
|
||||||
|
default?.isChecked = false
|
||||||
|
}
|
||||||
|
if (key != "tls_All_Supported") {
|
||||||
|
val all = preferenceScreen.findPreference<SwitchPreferenceCompat>("tls_All_Supported")
|
||||||
|
all?.isChecked = false
|
||||||
|
}
|
||||||
|
protocols.forEach { protocol ->
|
||||||
|
val tlsSwitchKey = "tls_${protocol.toLowerCase(Locale.getDefault())}"
|
||||||
|
if (tlsSwitchKey != key) {
|
||||||
|
val otherTLSSwitch =
|
||||||
|
preferenceScreen.findPreference<SwitchPreferenceCompat>(
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<alpha xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:fromAlpha="1.0"
|
||||||
|
android:toAlpha="0.0"
|
||||||
|
android:duration="1000"
|
||||||
|
/>
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="@drawable/vector_app_icon"/>
|
||||||
|
</layer-list>
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="@drawable/vector_app_icon"/>
|
||||||
|
</layer-list>
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<stroke android:width="1.0dip" android:color="#ffcccccc" />
|
||||||
|
<corners android:radius="12.0dip" />
|
||||||
|
</shape>
|
|
@ -0,0 +1,11 @@
|
||||||
|
<inset xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:insetBottom="0dp"
|
||||||
|
android:insetLeft="0dp"
|
||||||
|
android:insetRight="0dp"
|
||||||
|
android:insetTop="0dp">
|
||||||
|
<shape
|
||||||
|
android:shape="rectangle" android:tint="@color/address_background">
|
||||||
|
<stroke android:color="?attr/colorControlNormal" android:width="1dp" />
|
||||||
|
<corners android:radius="22dp" />
|
||||||
|
</shape>
|
||||||
|
</inset>
|
|
@ -0,0 +1,11 @@
|
||||||
|
<inset xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:insetBottom="3dp"
|
||||||
|
android:insetLeft="3dp"
|
||||||
|
android:insetRight="3dp"
|
||||||
|
android:insetTop="3dp">
|
||||||
|
<shape
|
||||||
|
android:shape="rectangle">
|
||||||
|
<stroke android:color="?attr/colorControlNormal" android:width="1dp" />
|
||||||
|
<corners android:radius="5dp" />
|
||||||
|
</shape>
|
||||||
|
</inset>
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="@drawable/vector_app_icon"/>
|
||||||
|
</layer-list>
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="@drawable/vector_app_icon"/>
|
||||||
|
</layer-list>
|
|
@ -0,0 +1,161 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:viewportWidth="64"
|
||||||
|
android:viewportHeight="64"
|
||||||
|
android:width="64dp"
|
||||||
|
android:height="64dp">
|
||||||
|
<group
|
||||||
|
android:rotation="-90">
|
||||||
|
<path
|
||||||
|
android:pathData="M-3.999487 31.99922A27.9995 27.9995 0 0 1 -59.99848 31.99922A27.9995 27.9995 0 0 1 -3.999487 31.99922Z"
|
||||||
|
android:strokeLineCap="square"
|
||||||
|
android:strokeLineJoin="round">
|
||||||
|
<aapt:attr
|
||||||
|
name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:startX="-59.99848"
|
||||||
|
android:startY="31.99922"
|
||||||
|
android:endX="-3.999483"
|
||||||
|
android:endY="31.99922"
|
||||||
|
android:tileMode="clamp">
|
||||||
|
<item
|
||||||
|
android:color="#54C0CD"
|
||||||
|
android:offset="0" />
|
||||||
|
<item
|
||||||
|
android:color="#40DD9D"
|
||||||
|
android:offset="1" />
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
</group>
|
||||||
|
<group
|
||||||
|
android:scaleX="0.2446361"
|
||||||
|
android:scaleY="0.2446361"
|
||||||
|
android:translateX="-90.46356"
|
||||||
|
android:translateY="-32.59816">
|
||||||
|
<path
|
||||||
|
android:pathData="M516.15807 333.26205l-4.23644 9.99629h-11.32999 -11.32999l-4.23644 -9.99629"
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:strokeColor="#000000"
|
||||||
|
android:strokeWidth="0.264583" />
|
||||||
|
<group
|
||||||
|
android:translateX="359.0252"
|
||||||
|
android:translateY="115.3219">
|
||||||
|
<path
|
||||||
|
android:pathData="M128.74043 197.67004v22.41127l-23.74528 -5.01339v-17.39788"
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:strokeColor="#000000"
|
||||||
|
android:strokeWidth="1.32292" />
|
||||||
|
<path
|
||||||
|
android:pathData="M104.99515 200.83206v14.23586L82.837366 210.5837v-9.75164"
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:strokeColor="#000000"
|
||||||
|
android:strokeWidth="1.32292" />
|
||||||
|
</group>
|
||||||
|
<group
|
||||||
|
android:rotation="-0"
|
||||||
|
android:scaleX="-1"
|
||||||
|
android:translateX="642.1581"
|
||||||
|
android:translateY="115.3218">
|
||||||
|
<path
|
||||||
|
android:pathData="M128.74043 197.67004v22.41127l-23.74528 -5.01339v-17.39788"
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:strokeColor="#000000"
|
||||||
|
android:strokeWidth="1.32292" />
|
||||||
|
<path
|
||||||
|
android:pathData="M104.99515 200.83206v14.23586L82.837366 210.5837v-9.75164"
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:strokeColor="#000000"
|
||||||
|
android:strokeWidth="1.32292" />
|
||||||
|
</group>
|
||||||
|
<path
|
||||||
|
android:pathData="M513.31018 318.17512h46.03014c0 0 0.0809 -5.1157 0 -5.7368 -0.50023 -3.84272 -1.20912 -4.15349 -2.404 -6.2027 -1.19488 -2.0492 -31.50157 -32.08606 -33.31329 -35.01628 -1.75432 -2.83739 -11.62819 -52.60827 -11.62819 -52.60827h-11.4032 -11.4032c0 0 -9.87387 49.77088 -11.62819 52.60827 -1.81172 2.93022 -32.11841 32.96708 -33.31329 35.01628 -1.19488 2.04921 -1.90377 2.35998 -2.404 6.2027 -0.0808 0.6211 0 5.7368 0 5.7368h46.03014"
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:strokeColor="#000000"
|
||||||
|
android:strokeWidth="2.64583" />
|
||||||
|
<path
|
||||||
|
android:pathData="M500.59164 333.40007h-2.78055v1.19954h-7.39536l-2.65015 -21.6077v-95.81955c0.0563 -7.71636 7.72843 -34.71998 12.82606 -34.87227 5.09763 0.15229 12.76976 27.15591 12.82606 34.87227v95.81955l-2.65015 21.6077h-7.39536v-1.19954h-2.78055"
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:strokeColor="#000000"
|
||||||
|
android:strokeWidth="1.32292" />
|
||||||
|
<path
|
||||||
|
android:pathData="M487.76558 217.17236H513.4177"
|
||||||
|
android:strokeColor="#000000"
|
||||||
|
android:strokeWidth="1.32292" />
|
||||||
|
<path
|
||||||
|
android:pathData="M487.76558 241.12725H513.4177"
|
||||||
|
android:strokeColor="#000000"
|
||||||
|
android:strokeWidth="1.32292" />
|
||||||
|
<path
|
||||||
|
android:pathData="M487.76558 265.08214H513.4177"
|
||||||
|
android:strokeColor="#000000"
|
||||||
|
android:strokeWidth="1.32292" />
|
||||||
|
<path
|
||||||
|
android:pathData="M487.76558 289.03702H513.4177"
|
||||||
|
android:strokeColor="#000000"
|
||||||
|
android:strokeWidth="1.32292" />
|
||||||
|
<path
|
||||||
|
android:pathData="M487.76558 312.99191H513.4177"
|
||||||
|
android:strokeColor="#000000"
|
||||||
|
android:strokeWidth="1.32292" />
|
||||||
|
<path
|
||||||
|
android:pathData="M487.76558 312.99191v32.8164h6.6234v-11.20867h-3.97324z"
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:strokeColor="#000000"
|
||||||
|
android:strokeWidth="1.32292" />
|
||||||
|
<path
|
||||||
|
android:pathData="M513.41771 312.99188v32.8164h-6.6234v-11.20867h3.97324z"
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:strokeColor="#000000"
|
||||||
|
android:strokeWidth="1.32292" />
|
||||||
|
<group
|
||||||
|
android:translateX="360.5539"
|
||||||
|
android:translateY="115.3219">
|
||||||
|
<path
|
||||||
|
android:pathData="M130.2622 222.7477A0.4003793 0.4003793 0 0 1 129.4615 222.7477A0.4003793 0.4003793 0 0 1 130.2622 222.7477Z"
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:strokeColor="#000000"
|
||||||
|
android:strokeWidth="0.0264583"
|
||||||
|
android:strokeLineCap="square"
|
||||||
|
android:strokeLineJoin="bevel" />
|
||||||
|
<path
|
||||||
|
android:pathData="M130.2622 224.8851A0.4003793 0.4003793 0 0 1 129.4615 224.8851A0.4003793 0.4003793 0 0 1 130.2622 224.8851Z"
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:strokeColor="#000000"
|
||||||
|
android:strokeWidth="0.0264583"
|
||||||
|
android:strokeLineCap="square"
|
||||||
|
android:strokeLineJoin="bevel" />
|
||||||
|
<path
|
||||||
|
android:pathData="M130.2622 227.0326A0.4003793 0.4003793 0 0 1 129.4615 227.0326A0.4003793 0.4003793 0 0 1 130.2622 227.0326Z"
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:strokeColor="#000000"
|
||||||
|
android:strokeWidth="0.0264583"
|
||||||
|
android:strokeLineCap="square"
|
||||||
|
android:strokeLineJoin="bevel" />
|
||||||
|
</group>
|
||||||
|
<group
|
||||||
|
android:translateX="380.9057"
|
||||||
|
android:translateY="115.3219">
|
||||||
|
<path
|
||||||
|
android:pathData="M130.2622 222.7477A0.4003793 0.4003793 0 0 1 129.4615 222.7477A0.4003793 0.4003793 0 0 1 130.2622 222.7477Z"
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:strokeColor="#000000"
|
||||||
|
android:strokeWidth="0.0264583"
|
||||||
|
android:strokeLineCap="square"
|
||||||
|
android:strokeLineJoin="bevel" />
|
||||||
|
<path
|
||||||
|
android:pathData="M130.2622 224.8851A0.4003793 0.4003793 0 0 1 129.4615 224.8851A0.4003793 0.4003793 0 0 1 130.2622 224.8851Z"
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:strokeColor="#000000"
|
||||||
|
android:strokeWidth="0.0264583"
|
||||||
|
android:strokeLineCap="square"
|
||||||
|
android:strokeLineJoin="bevel" />
|
||||||
|
<path
|
||||||
|
android:pathData="M130.2622 227.0326A0.4003793 0.4003793 0 0 1 129.4615 227.0326A0.4003793 0.4003793 0 0 1 130.2622 227.0326Z"
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:strokeColor="#000000"
|
||||||
|
android:strokeWidth="0.0264583"
|
||||||
|
android:strokeLineCap="square"
|
||||||
|
android:strokeLineJoin="bevel" />
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
</vector>
|
|
@ -0,0 +1,10 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M12,2C6.47,2 2,6.47 2,12s4.47,10 10,10 10,-4.47 10,-10S17.53,2 12,2zM12,20c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8 8,3.59 8,8 -3.59,8 -8,8zM15.59,7L12,10.59 8.41,7 7,8.41 10.59,12 7,15.59 8.41,17 12,13.41 15.59,17 17,15.59 13.41,12 17,8.41z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,10 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M17.81,4.47c-0.08,0 -0.16,-0.02 -0.23,-0.06C15.66,3.42 14,3 12.01,3c-1.98,0 -3.86,0.47 -5.57,1.41 -0.24,0.13 -0.54,0.04 -0.68,-0.2 -0.13,-0.24 -0.04,-0.55 0.2,-0.68C7.82,2.52 9.86,2 12.01,2c2.13,0 3.99,0.47 6.03,1.52 0.25,0.13 0.34,0.43 0.21,0.67 -0.09,0.18 -0.26,0.28 -0.44,0.28zM3.5,9.72c-0.1,0 -0.2,-0.03 -0.29,-0.09 -0.23,-0.16 -0.28,-0.47 -0.12,-0.7 0.99,-1.4 2.25,-2.5 3.75,-3.27C9.98,4.04 14,4.03 17.15,5.65c1.5,0.77 2.76,1.86 3.75,3.25 0.16,0.22 0.11,0.54 -0.12,0.7 -0.23,0.16 -0.54,0.11 -0.7,-0.12 -0.9,-1.26 -2.04,-2.25 -3.39,-2.94 -2.87,-1.47 -6.54,-1.47 -9.4,0.01 -1.36,0.7 -2.5,1.7 -3.4,2.96 -0.08,0.14 -0.23,0.21 -0.39,0.21zM9.75,21.79c-0.13,0 -0.26,-0.05 -0.35,-0.15 -0.87,-0.87 -1.34,-1.43 -2.01,-2.64 -0.69,-1.23 -1.05,-2.73 -1.05,-4.34 0,-2.97 2.54,-5.39 5.66,-5.39s5.66,2.42 5.66,5.39c0,0.28 -0.22,0.5 -0.5,0.5s-0.5,-0.22 -0.5,-0.5c0,-2.42 -2.09,-4.39 -4.66,-4.39s-4.66,1.97 -4.66,4.39c0,1.44 0.32,2.77 0.93,3.85 0.64,1.15 1.08,1.64 1.85,2.42 0.19,0.2 0.19,0.51 0,0.71 -0.11,0.1 -0.24,0.15 -0.37,0.15zM16.92,19.94c-1.19,0 -2.24,-0.3 -3.1,-0.89 -1.49,-1.01 -2.38,-2.65 -2.38,-4.39 0,-0.28 0.22,-0.5 0.5,-0.5s0.5,0.22 0.5,0.5c0,1.41 0.72,2.74 1.94,3.56 0.71,0.48 1.54,0.71 2.54,0.71 0.24,0 0.64,-0.03 1.04,-0.1 0.27,-0.05 0.53,0.13 0.58,0.41 0.05,0.27 -0.13,0.53 -0.41,0.58 -0.57,0.11 -1.07,0.12 -1.21,0.12zM14.91,22c-0.04,0 -0.09,-0.01 -0.13,-0.02 -1.59,-0.44 -2.63,-1.03 -3.72,-2.1 -1.4,-1.39 -2.17,-3.24 -2.17,-5.22 0,-1.62 1.38,-2.94 3.08,-2.94s3.08,1.32 3.08,2.94c0,1.07 0.93,1.94 2.08,1.94s2.08,-0.87 2.08,-1.94c0,-3.77 -3.25,-6.83 -7.25,-6.83 -2.84,0 -5.44,1.58 -6.61,4.03 -0.39,0.81 -0.59,1.76 -0.59,2.8 0,0.78 0.07,2.01 0.67,3.61 0.1,0.26 -0.03,0.55 -0.29,0.64 -0.26,0.1 -0.55,-0.04 -0.64,-0.29 -0.49,-1.31 -0.73,-2.61 -0.73,-3.96 0,-1.2 0.23,-2.29 0.68,-3.24 1.33,-2.79 4.28,-4.6 7.51,-4.6 4.55,0 8.25,3.51 8.25,7.83 0,1.62 -1.38,2.94 -3.08,2.94s-3.08,-1.32 -3.08,-2.94c0,-1.07 -0.93,-1.94 -2.08,-1.94s-2.08,0.87 -2.08,1.94c0,1.71 0.66,3.31 1.87,4.51 0.95,0.94 1.86,1.46 3.27,1.85 0.27,0.07 0.42,0.35 0.35,0.61 -0.05,0.23 -0.26,0.38 -0.47,0.38z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,10 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12 19,6.41z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,10 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M20,4H4C2.89,4 2,4.9 2,6v12c0,1.1 0.89,2 2,2h16c1.1,0 2,-0.9 2,-2V6C22,4.9 21.11,4 20,4zM20,18H4V8h16V18zM18,17h-6v-2h6V17zM7.5,17l-1.41,-1.41L8.67,13l-2.59,-2.59L7.5,9l4,4L7.5,17z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,10 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M9,16.17L4.83,12l-1.42,1.41L9,19 21,7l-1.41,-1.41L9,16.17z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,10 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="@color/colorAccent">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M12,5.69l5,4.5V18h-2v-6H9v6H7v-7.81l5,-4.5M12,3L2,12h3v8h6v-6h2v6h6v-8h3L12,3z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,10 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M17,7h-4v2h4c1.65,0 3,1.35 3,3s-1.35,3 -3,3h-4v2h4c2.76,0 5,-2.24 5,-5s-2.24,-5 -5,-5zM11,15L7,15c-1.65,0 -3,-1.35 -3,-3s1.35,-3 3,-3h4L11,7L7,7c-2.76,0 -5,2.24 -5,5s2.24,5 5,5h4v-2zM8,11h8v2L8,13z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,10 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M19,4L5,4c-1.11,0 -2,0.9 -2,2v12c0,1.1 0.89,2 2,2h4v-2L5,18L5,8h14v10h-4v2h4c1.1,0 2,-0.9 2,-2L21,6c0,-1.1 -0.89,-2 -2,-2zM12,10l-4,4h3v6h2v-6h3l-4,-4z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,10 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M12,8c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM12,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM12,16c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,10 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M21,19V5c0,-1.1 -0.9,-2 -2,-2H5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2zM8.5,13.5l2.5,3.01L14.5,12l4.5,6H5l3.5,-4.5z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,10 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,13 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="20dp"
|
||||||
|
android:height="20dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M14,9l-1.06,-1.06l-2.19,2.19l0,-7.13l-1.5,0l0,7.13l-2.19,-2.19l-1.06,1.06l4,4z"/>
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M14.5,13v1.5h-9V13H4v1.5C4,15.33 4.67,16 5.5,16h9c0.83,0 1.5,-0.67 1.5,-1.5V13H14.5z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<font-family xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<font
|
||||||
|
android:fontStyle="normal"
|
||||||
|
android:fontWeight="400"
|
||||||
|
android:font="@font/jet_brains_mono"/>
|
||||||
|
</font-family>
|
Binary file not shown.
|
@ -0,0 +1,125 @@
|
||||||
|
<layout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||||
|
<data>
|
||||||
|
<variable name="viewmodel" type="corewala.buran.ui.GemViewModel" />
|
||||||
|
</data>
|
||||||
|
|
||||||
|
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:id="@+id/root_coord"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:fitsSystemWindows="true"
|
||||||
|
tools:context=".ui.GemActivity">
|
||||||
|
|
||||||
|
<com.google.android.material.appbar.AppBarLayout
|
||||||
|
android:id="@+id/app_bar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="@color/header_background"
|
||||||
|
android:fitsSystemWindows="true">
|
||||||
|
|
||||||
|
<com.google.android.material.appbar.CollapsingToolbarLayout
|
||||||
|
android:id="@+id/toolbar_layout"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:fitsSystemWindows="true"
|
||||||
|
app:contentScrim="?attr/colorPrimaryDark"
|
||||||
|
app:layout_scrollFlags="scroll|enterAlways"
|
||||||
|
app:toolbarId="@+id/toolbar">
|
||||||
|
|
||||||
|
<RelativeLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="@dimen/default_margin">
|
||||||
|
|
||||||
|
<RelativeLayout android:id="@+id/address_bar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:paddingBottom="@dimen/default_margin">
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.AppCompatImageButton
|
||||||
|
android:id="@+id/home"
|
||||||
|
android:layout_width="@dimen/button_size"
|
||||||
|
android:layout_height="@dimen/button_size"
|
||||||
|
android:layout_centerVertical="true"
|
||||||
|
android:layout_margin="@dimen/button_margin"
|
||||||
|
android:background="?android:attr/selectableItemBackgroundBorderless"
|
||||||
|
android:src="@drawable/vector_home" />
|
||||||
|
|
||||||
|
<RelativeLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_centerVertical="true"
|
||||||
|
android:layout_toEndOf="@+id/home"
|
||||||
|
android:layout_toStartOf="@+id/more">
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.AppCompatEditText
|
||||||
|
android:id="@+id/address_edit"
|
||||||
|
android:background="@drawable/drawable_filled_rounded_rect"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:paddingTop="12dp"
|
||||||
|
android:paddingBottom="12dp"
|
||||||
|
android:paddingLeft="12dp"
|
||||||
|
android:paddingRight="12dp"
|
||||||
|
android:drawablePadding="4dp"
|
||||||
|
android:inputType="textNoSuggestions|textUri"
|
||||||
|
android:singleLine="true"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:imeOptions="actionGo"
|
||||||
|
android:hint="@string/main_input_hint" />
|
||||||
|
|
||||||
|
|
||||||
|
</RelativeLayout>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.AppCompatImageButton
|
||||||
|
android:id="@+id/more"
|
||||||
|
android:layout_width="@dimen/button_size"
|
||||||
|
android:layout_height="@dimen/button_size"
|
||||||
|
android:layout_alignParentRight="true"
|
||||||
|
android:layout_centerVertical="true"
|
||||||
|
android:layout_margin="@dimen/button_margin"
|
||||||
|
android:background="?android:attr/selectableItemBackgroundBorderless"
|
||||||
|
android:src="@drawable/vector_overflow" />
|
||||||
|
|
||||||
|
|
||||||
|
</RelativeLayout>
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
android:id="@+id/progress_bar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="2dp"
|
||||||
|
android:indeterminateTint="@color/stroke"
|
||||||
|
style="?android:attr/progressBarStyleHorizontal"
|
||||||
|
android:layout_below="@+id/address_bar"
|
||||||
|
android:indeterminate="true"/>
|
||||||
|
|
||||||
|
</RelativeLayout>
|
||||||
|
</com.google.android.material.appbar.CollapsingToolbarLayout>
|
||||||
|
|
||||||
|
</com.google.android.material.appbar.AppBarLayout>
|
||||||
|
|
||||||
|
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
|
android:id="@+id/pull_to_refresh"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:layout_behavior="@string/appbar_scrolling_view_behavior">
|
||||||
|
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/gemtext_recycler"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:clipToPadding="false"
|
||||||
|
android:scrollbars="vertical"
|
||||||
|
android:scrollbarStyle="outsideOverlay"
|
||||||
|
android:paddingTop="@dimen/screen_margin"
|
||||||
|
android:paddingBottom="@dimen/screen_margin"
|
||||||
|
/>
|
||||||
|
|
||||||
|
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||||
|
|
||||||
|
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||||
|
</layout>
|
|
@ -0,0 +1,20 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.Toolbar
|
||||||
|
android:id="@+id/settings_toolbar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:title="@string/settings"/>
|
||||||
|
|
||||||
|
<RelativeLayout
|
||||||
|
android:id="@+id/settings_container"
|
||||||
|
android:layout_below="@+id/settings_toolbar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
</RelativeLayout>
|
||||||
|
</RelativeLayout>
|
|
@ -0,0 +1,39 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<RelativeLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:id="@+id/bookmark_layout"
|
||||||
|
android:clickable="true"
|
||||||
|
android:focusable="true"
|
||||||
|
android:padding="@dimen/default_margin_big"
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.AppCompatTextView
|
||||||
|
android:id="@+id/bookmark_name"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
tools:text="My Homepage"
|
||||||
|
android:layout_toLeftOf="@+id/bookmark_overflow"
|
||||||
|
android:textColor="@color/vibrant_stroke"
|
||||||
|
android:textSize="@dimen/text_large" />
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.AppCompatTextView
|
||||||
|
android:id="@+id/bookmark_uri"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
tools:text="gemini://myhomepage.gmi"
|
||||||
|
android:textSize="@dimen/text_small"
|
||||||
|
android:layout_toLeftOf="@+id/bookmark_overflow"
|
||||||
|
android:layout_below="@+id/bookmark_name"/>
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.AppCompatImageButton
|
||||||
|
android:id="@+id/bookmark_overflow"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="?android:attr/selectableItemBackgroundBorderless"
|
||||||
|
android:layout_alignParentRight="true"
|
||||||
|
android:layout_centerVertical="true"
|
||||||
|
android:src="@drawable/vector_overflow" />
|
||||||
|
|
||||||
|
</RelativeLayout>
|
|
@ -0,0 +1,115 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<RelativeLayout
|
||||||
|
android:id="@+id/header"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:paddingTop="@dimen/default_margin">
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.AppCompatImageButton
|
||||||
|
android:id="@+id/close_tab_dialog"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_centerVertical="true"
|
||||||
|
android:layout_alignParentStart="true"
|
||||||
|
android:layout_margin="@dimen/button_margin"
|
||||||
|
android:background="?android:attr/selectableItemBackgroundBorderless"
|
||||||
|
android:src="@drawable/vector_close" />
|
||||||
|
|
||||||
|
</RelativeLayout>
|
||||||
|
<androidx.core.widget.NestedScrollView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_below="@+id/header">
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:paddingLeft="@dimen/default_margin_big"
|
||||||
|
android:paddingRight="@dimen/default_margin_big"
|
||||||
|
android:paddingBottom="@dimen/default_margin_big"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:paddingTop="@dimen/default_margin_big"
|
||||||
|
android:textColor="@color/stroke"
|
||||||
|
android:text="@string/about_body"/>
|
||||||
|
|
||||||
|
<!-- Version -->
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/version_label"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textColor="@color/stroke"
|
||||||
|
tools:text="1.0.0 alpha delta"/>
|
||||||
|
|
||||||
|
<!-- Copyright -->
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:paddingBottom="@dimen/default_margin"
|
||||||
|
android:textColor="@color/stroke"
|
||||||
|
android:text="@string/copyright"/>
|
||||||
|
|
||||||
|
<!-- Source button -->
|
||||||
|
<androidx.appcompat.widget.AppCompatButton
|
||||||
|
android:id="@+id/source_button"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Source"/>
|
||||||
|
|
||||||
|
<!-- DIV -->
|
||||||
|
<View
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="1dp"
|
||||||
|
android:layout_marginBottom="@dimen/default_margin"
|
||||||
|
android:layout_marginTop="@dimen/default_margin"
|
||||||
|
android:alpha="0.5"
|
||||||
|
android:background="?attr/colorOnSurface" />
|
||||||
|
|
||||||
|
<!-- Ariane source attribution-->
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:paddingBottom="@dimen/default_margin"
|
||||||
|
android:textColor="@color/stroke"
|
||||||
|
android:text="@string/about_ariane_source"/>
|
||||||
|
|
||||||
|
<!-- Font Attribution -->
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:paddingBottom="@dimen/default_margin"
|
||||||
|
android:textColor="@color/stroke"
|
||||||
|
android:text="@string/about_font"/>
|
||||||
|
|
||||||
|
<!-- Glyph Attribution -->
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:paddingBottom="@dimen/default_margin"
|
||||||
|
android:textColor="@color/stroke"
|
||||||
|
android:text="@string/about_glyphs"/>
|
||||||
|
|
||||||
|
<!-- DIV -->
|
||||||
|
<View
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="1dp"
|
||||||
|
android:layout_marginBottom="@dimen/default_margin"
|
||||||
|
android:layout_marginTop="@dimen/default_margin"
|
||||||
|
android:alpha="0.5"
|
||||||
|
android:background="?attr/colorOnSurface" />
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
</androidx.core.widget.NestedScrollView>
|
||||||
|
|
||||||
|
</RelativeLayout>
|
|
@ -0,0 +1,54 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.Toolbar
|
||||||
|
android:id="@+id/bookmarks_toolbar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_marginTop="@dimen/default_margin"
|
||||||
|
android:layout_height="@dimen/bar_height"
|
||||||
|
app:menu="@menu/bookmark_import_export"
|
||||||
|
app:title="@string/bookmarks"/>
|
||||||
|
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/bookmarks_recycler"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_below="@+id/bookmarks_toolbar"/>
|
||||||
|
|
||||||
|
<RelativeLayout
|
||||||
|
android:id="@+id/bookmarks_empty_layout"
|
||||||
|
android:visibility="gone"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:layout_centerInParent="true"
|
||||||
|
android:paddingBottom="60dp"
|
||||||
|
android:gravity="center_horizontal">
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.AppCompatImageView
|
||||||
|
android:layout_width="100dp"
|
||||||
|
android:layout_height="100dp"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.AppCompatTextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/bookmarks_empty"
|
||||||
|
android:paddingTop="@dimen/default_margin_big"/>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</RelativeLayout>
|
||||||
|
|
||||||
|
|
||||||
|
</RelativeLayout>
|
|
@ -0,0 +1,43 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<corewala.buran.ui.content_image.TouchImageView
|
||||||
|
android:id="@+id/image_view"
|
||||||
|
android:scaleType="centerInside"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_centerHorizontal="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RelativeLayout
|
||||||
|
android:id="@+id/header"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:paddingTop="@dimen/default_margin">
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.AppCompatImageButton
|
||||||
|
android:id="@+id/close_image_content_dialog"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_centerVertical="true"
|
||||||
|
android:layout_alignParentStart="true"
|
||||||
|
android:layout_margin="@dimen/button_margin"
|
||||||
|
android:background="?android:attr/selectableItemBackgroundBorderless"
|
||||||
|
android:src="@drawable/vector_close" />
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.AppCompatImageButton
|
||||||
|
android:id="@+id/image_overflow"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_centerVertical="true"
|
||||||
|
android:layout_alignParentEnd="true"
|
||||||
|
android:layout_margin="@dimen/button_margin"
|
||||||
|
android:background="?android:attr/selectableItemBackgroundBorderless"
|
||||||
|
android:src="@drawable/vector_overflow" />
|
||||||
|
|
||||||
|
</RelativeLayout>
|
||||||
|
|
||||||
|
</RelativeLayout>
|
|
@ -0,0 +1,50 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<RelativeLayout
|
||||||
|
android:id="@+id/header"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:paddingTop="@dimen/default_margin">
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.AppCompatImageButton
|
||||||
|
android:id="@+id/close_text_content_dialog"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_centerVertical="true"
|
||||||
|
android:layout_alignParentStart="true"
|
||||||
|
android:layout_margin="@dimen/button_margin"
|
||||||
|
android:background="?android:attr/selectableItemBackgroundBorderless"
|
||||||
|
android:src="@drawable/vector_close" />
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.AppCompatImageButton
|
||||||
|
android:id="@+id/history_overflow"
|
||||||
|
android:visibility="gone"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_centerVertical="true"
|
||||||
|
android:layout_alignParentEnd="true"
|
||||||
|
android:layout_margin="@dimen/button_margin"
|
||||||
|
android:background="?android:attr/selectableItemBackgroundBorderless"
|
||||||
|
android:src="@drawable/vector_overflow" />
|
||||||
|
|
||||||
|
</RelativeLayout>
|
||||||
|
<androidx.core.widget.NestedScrollView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:padding="@dimen/default_margin"
|
||||||
|
android:layout_below="@+id/header">
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.AppCompatTextView
|
||||||
|
android:id="@+id/text_content"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textSize="@dimen/default_text_size"
|
||||||
|
android:textIsSelectable="true"/>
|
||||||
|
|
||||||
|
|
||||||
|
</androidx.core.widget.NestedScrollView>
|
||||||
|
</RelativeLayout>
|
|
@ -0,0 +1,40 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<RelativeLayout
|
||||||
|
android:id="@+id/header"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:paddingTop="@dimen/default_margin">
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.AppCompatImageButton
|
||||||
|
android:id="@+id/close_tab_dialog"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_centerVertical="true"
|
||||||
|
android:layout_alignParentStart="true"
|
||||||
|
android:layout_margin="@dimen/button_margin"
|
||||||
|
android:background="?android:attr/selectableItemBackgroundBorderless"
|
||||||
|
android:src="@drawable/vector_close" />
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.AppCompatImageButton
|
||||||
|
android:id="@+id/history_overflow"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_centerVertical="true"
|
||||||
|
android:layout_alignParentEnd="true"
|
||||||
|
android:layout_margin="@dimen/button_margin"
|
||||||
|
android:background="?android:attr/selectableItemBackgroundBorderless"
|
||||||
|
android:src="@drawable/vector_overflow" />
|
||||||
|
|
||||||
|
</RelativeLayout>
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/history_recycler"
|
||||||
|
android:layout_below="@+id/header"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content" />
|
||||||
|
|
||||||
|
</RelativeLayout>
|
|
@ -0,0 +1,62 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<RelativeLayout
|
||||||
|
android:id="@+id/header"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:paddingTop="@dimen/default_margin">
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.AppCompatImageButton
|
||||||
|
android:id="@+id/close_input_query_dialog"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_centerVertical="true"
|
||||||
|
android:layout_alignParentStart="true"
|
||||||
|
android:layout_margin="@dimen/button_margin"
|
||||||
|
android:background="?android:attr/selectableItemBackgroundBorderless"
|
||||||
|
android:src="@drawable/vector_close" />
|
||||||
|
|
||||||
|
</RelativeLayout>
|
||||||
|
<androidx.core.widget.NestedScrollView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_below="@+id/header">
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:paddingLeft="@dimen/default_margin_big"
|
||||||
|
android:paddingRight="@dimen/default_margin_big"
|
||||||
|
android:paddingBottom="@dimen/default_margin_big"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.AppCompatTextView
|
||||||
|
android:id="@+id/query_text"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:paddingBottom="@dimen/default_margin"
|
||||||
|
tools:text="Type your query"/>
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.AppCompatEditText
|
||||||
|
android:id="@+id/query_input"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:paddingBottom="@dimen/default_margin"/>
|
||||||
|
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.AppCompatButton
|
||||||
|
android:id="@+id/query_submit_button"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="end"
|
||||||
|
android:text="@string/submit"/>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
</androidx.core.widget.NestedScrollView>
|
||||||
|
|
||||||
|
</RelativeLayout>
|
|
@ -0,0 +1,30 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent" android:layout_height="wrap_content"
|
||||||
|
android:padding="@dimen/default_margin">
|
||||||
|
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.AppCompatEditText
|
||||||
|
android:id="@+id/home_edit_text"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content" />
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.AppCompatButton
|
||||||
|
android:id="@+id/set_home_button"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
|
||||||
|
android:layout_alignParentRight="true"
|
||||||
|
android:text="Update"
|
||||||
|
android:layout_below="@+id/home_edit_text"/>
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.AppCompatButton
|
||||||
|
android:id="@+id/use_current_button"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_toLeftOf="@+id/set_home_button"
|
||||||
|
android:text="Use current"
|
||||||
|
android:layout_below="@+id/home_edit_text"/>
|
||||||
|
</RelativeLayout>
|
|
@ -0,0 +1,43 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<RelativeLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<RelativeLayout
|
||||||
|
android:id="@+id/header"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:paddingTop="@dimen/default_margin"
|
||||||
|
android:paddingBottom="@dimen/default_margin">
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.AppCompatImageButton
|
||||||
|
android:id="@+id/close_tab_dialog"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_centerVertical="true"
|
||||||
|
android:layout_alignParentStart="true"
|
||||||
|
android:layout_margin="@dimen/button_margin"
|
||||||
|
android:background="?android:attr/selectableItemBackgroundBorderless"
|
||||||
|
android:src="@drawable/vector_close" />
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.AppCompatImageButton
|
||||||
|
android:id="@+id/tab_dialog_overflow"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_centerVertical="true"
|
||||||
|
android:layout_alignParentEnd="true"
|
||||||
|
android:visibility="gone"
|
||||||
|
android:layout_margin="@dimen/button_margin"
|
||||||
|
android:background="?android:attr/selectableItemBackgroundBorderless"
|
||||||
|
android:src="@drawable/vector_overflow" />
|
||||||
|
|
||||||
|
</RelativeLayout>
|
||||||
|
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/tab_dialog_recycler"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_below="@+id/header" />
|
||||||
|
|
||||||
|
</RelativeLayout>
|
|
@ -0,0 +1,50 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.Toolbar
|
||||||
|
android:id="@+id/bookmark_toolbar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_marginTop="@dimen/default_margin"
|
||||||
|
android:layout_height="@dimen/bar_height"
|
||||||
|
app:title="@string/add_bookmark"/>
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.LinearLayoutCompat
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_below="@+id/bookmark_toolbar"
|
||||||
|
android:padding="@dimen/default_margin_big"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.AppCompatTextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/name" />
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.AppCompatEditText
|
||||||
|
android:id="@+id/bookmark_name"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="text"
|
||||||
|
android:lines="1"
|
||||||
|
android:maxLines="1"/>
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.AppCompatTextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/gemini_uri" />
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.AppCompatEditText
|
||||||
|
android:id="@+id/bookmark_uri"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="textUri"
|
||||||
|
android:lines="1"
|
||||||
|
android:maxLines="1"/>
|
||||||
|
|
||||||
|
</androidx.appcompat.widget.LinearLayoutCompat>
|
||||||
|
|
||||||
|
|
||||||
|
</RelativeLayout>
|
|
@ -0,0 +1,56 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<RelativeLayout
|
||||||
|
android:layout_width="fill_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginLeft="@dimen/screen_margin"
|
||||||
|
android:layout_marginTop="@dimen/default_margin"
|
||||||
|
android:layout_marginRight="@dimen/screen_margin"
|
||||||
|
android:layout_marginBottom="@dimen/default_margin"
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.AppCompatTextView
|
||||||
|
android:textSize="@dimen/default_text_size"
|
||||||
|
android:textColor="@color/stroke"
|
||||||
|
android:id="@id/show_code_block"
|
||||||
|
android:background="?android:selectableItemBackground"
|
||||||
|
android:focusable="true" android:clickable="true"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="@dimen/default_margin_small"
|
||||||
|
android:layout_marginBottom="@dimen/default_margin_small"
|
||||||
|
android:text="@string/show_code"
|
||||||
|
android:drawablePadding="4.0dip"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<HorizontalScrollView
|
||||||
|
android:background="@drawable/block_background"
|
||||||
|
android:layout_width="fill_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="@dimen/def_quarter"
|
||||||
|
android:layout_marginBottom="@dimen/def_quarter"
|
||||||
|
android:layout_below="@id/show_code_block">
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.AppCompatTextView
|
||||||
|
android:scrollbarStyle="insideOverlay"
|
||||||
|
android:textSize="@dimen/code_text_size"
|
||||||
|
android:typeface="monospace"
|
||||||
|
android:textColor="@color/stroke"
|
||||||
|
android:id="@id/gemtext_text_monospace_textview"
|
||||||
|
android:paddingLeft="@dimen/default_margin_big"
|
||||||
|
android:paddingTop="@dimen/default_margin_big"
|
||||||
|
android:paddingRight="@dimen/default_margin_big"
|
||||||
|
android:paddingBottom="0.0dip"
|
||||||
|
android:scrollbars="horizontal|vertical"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:scrollHorizontally="true"
|
||||||
|
android:isScrollContainer="true"
|
||||||
|
android:overScrollMode="ifContentScrolls"
|
||||||
|
android:textIsSelectable="true"
|
||||||
|
android:fontFamily="@font/code_font"
|
||||||
|
app:fontFamily="@font/code_font"
|
||||||
|
app:lineHeight="@dimen/code_text_size"/>
|
||||||
|
|
||||||
|
</HorizontalScrollView>
|
||||||
|
</RelativeLayout>
|
|
@ -0,0 +1,14 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.appcompat.widget.AppCompatTextView
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:id="@+id/gemtext_text_textview"
|
||||||
|
android:textSize="@dimen/h1_text_size"
|
||||||
|
android:textColor="@color/stroke"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textIsSelectable="true"
|
||||||
|
android:layout_marginLeft="@dimen/screen_margin"
|
||||||
|
android:layout_marginRight="@dimen/screen_margin"
|
||||||
|
android:layout_marginTop="@dimen/default_margin"
|
||||||
|
android:layout_marginBottom="@dimen/default_margin_tiny" />
|
|
@ -0,0 +1,14 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.appcompat.widget.AppCompatTextView
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:id="@+id/gemtext_text_textview"
|
||||||
|
android:textSize="@dimen/h2_text_size"
|
||||||
|
android:textColor="@color/stroke"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textIsSelectable="true"
|
||||||
|
android:layout_marginLeft="@dimen/screen_margin"
|
||||||
|
android:layout_marginRight="@dimen/screen_margin"
|
||||||
|
android:layout_marginTop="@dimen/default_margin"
|
||||||
|
android:layout_marginBottom="@dimen/default_margin_tiny" />
|
|
@ -0,0 +1,14 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.appcompat.widget.AppCompatTextView
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:id="@+id/gemtext_text_textview"
|
||||||
|
android:textSize="@dimen/h3_text_size"
|
||||||
|
android:textColor="@color/stroke"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textIsSelectable="true"
|
||||||
|
android:layout_marginLeft="@dimen/screen_margin"
|
||||||
|
android:layout_marginRight="@dimen/screen_margin"
|
||||||
|
android:layout_marginTop="@dimen/default_margin"
|
||||||
|
android:layout_marginBottom="@dimen/default_margin_tiny" />
|
|
@ -0,0 +1,34 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.AppCompatTextView
|
||||||
|
android:id="@+id/gemtext_text_link"
|
||||||
|
android:textColor="@color/stroke"
|
||||||
|
android:textSize="@dimen/default_text_size"
|
||||||
|
android:clickable="true"
|
||||||
|
android:focusable="true"
|
||||||
|
android:drawableEnd="@drawable/vector_photo"
|
||||||
|
android:drawablePadding="4dp"
|
||||||
|
android:background="?android:attr/selectableItemBackground"
|
||||||
|
android:layout_marginLeft="@dimen/screen_margin"
|
||||||
|
android:layout_marginRight="@dimen/screen_margin"
|
||||||
|
tools:text="an image"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content" />
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.AppCompatImageView
|
||||||
|
android:id="@+id/gemtext_inline_image"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginLeft="@dimen/screen_margin"
|
||||||
|
android:layout_marginRight="@dimen/screen_margin"
|
||||||
|
android:layout_marginTop="@dimen/default_margin"
|
||||||
|
android:layout_marginBottom="@dimen/default_margin"
|
||||||
|
android:background="#ffffff"
|
||||||
|
android:visibility="gone"
|
||||||
|
android:adjustViewBounds="true"
|
||||||
|
android:layout_below="@+id/gemtext_text_link"/>
|
||||||
|
</RelativeLayout>
|
|
@ -0,0 +1,58 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<RelativeLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginLeft="@dimen/screen_margin"
|
||||||
|
android:layout_marginRight="@dimen/screen_margin"
|
||||||
|
android:layout_marginTop="@dimen/default_margin"
|
||||||
|
android:layout_marginBottom="@dimen/default_margin">
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.AppCompatButton
|
||||||
|
android:id="@+id/show_code_block"
|
||||||
|
android:textColor="@color/stroke"
|
||||||
|
android:layout_marginTop="@dimen/default_margin_small"
|
||||||
|
android:layout_marginBottom="@dimen/default_margin_small"
|
||||||
|
android:textSize="@dimen/large_text_size"
|
||||||
|
android:clickable="true"
|
||||||
|
android:focusable="true"
|
||||||
|
android:padding="@dimen/accessibility_button_padding"
|
||||||
|
android:textAllCaps="false"
|
||||||
|
android:backgroundTint="@color/accessibility_button_background"
|
||||||
|
android:textAlignment="viewStart"
|
||||||
|
android:drawablePadding="4dp"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/show_code"
|
||||||
|
tools:text="Show code: Ascii art banner: Spacewalk - Updates from around geminispace"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<HorizontalScrollView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_below="@+id/show_code_block">
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.AppCompatTextView
|
||||||
|
android:id="@+id/gemtext_text_monospace_textview"
|
||||||
|
android:textSize="@dimen/code_text_size"
|
||||||
|
app:lineHeight="@dimen/code_text_size"
|
||||||
|
app:fontFamily="@font/code_font"
|
||||||
|
android:fontFamily="@font/code_font"
|
||||||
|
android:typeface="monospace"
|
||||||
|
android:textColor="@color/stroke"
|
||||||
|
android:textIsSelectable="true"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:paddingLeft="@dimen/default_margin"
|
||||||
|
android:paddingRight="@dimen/default_margin"
|
||||||
|
android:paddingTop="@dimen/default_margin"
|
||||||
|
android:paddingBottom="@dimen/default_margin"
|
||||||
|
android:isScrollContainer="true"
|
||||||
|
android:overScrollMode="ifContentScrolls"
|
||||||
|
android:scrollHorizontally="true"
|
||||||
|
android:scrollbarStyle="insideOverlay"
|
||||||
|
android:scrollbars="vertical|horizontal"/>
|
||||||
|
</HorizontalScrollView>
|
||||||
|
</RelativeLayout>
|
|
@ -0,0 +1,14 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.appcompat.widget.AppCompatTextView
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:id="@+id/gemtext_text_textview"
|
||||||
|
android:textSize="@dimen/large_h1_text_size"
|
||||||
|
android:textColor="@color/stroke"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textIsSelectable="true"
|
||||||
|
android:layout_marginLeft="@dimen/screen_margin"
|
||||||
|
android:layout_marginRight="@dimen/screen_margin"
|
||||||
|
android:layout_marginTop="@dimen/default_margin"
|
||||||
|
android:layout_marginBottom="@dimen/default_margin_tiny" />
|
|
@ -0,0 +1,14 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.appcompat.widget.AppCompatTextView
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:id="@+id/gemtext_text_textview"
|
||||||
|
android:textSize="@dimen/large_h2_text_size"
|
||||||
|
android:textColor="@color/stroke"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textIsSelectable="true"
|
||||||
|
android:layout_marginLeft="@dimen/screen_margin"
|
||||||
|
android:layout_marginRight="@dimen/screen_margin"
|
||||||
|
android:layout_marginTop="@dimen/default_margin"
|
||||||
|
android:layout_marginBottom="@dimen/default_margin_tiny" />
|
|
@ -0,0 +1,14 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.appcompat.widget.AppCompatTextView
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:id="@+id/gemtext_text_textview"
|
||||||
|
android:textSize="@dimen/large_h3_text_size"
|
||||||
|
android:textColor="@color/stroke"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textIsSelectable="true"
|
||||||
|
android:layout_marginLeft="@dimen/screen_margin"
|
||||||
|
android:layout_marginRight="@dimen/screen_margin"
|
||||||
|
android:layout_marginTop="@dimen/default_margin"
|
||||||
|
android:layout_marginBottom="@dimen/default_margin_tiny" />
|
|
@ -0,0 +1,37 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.AppCompatButton
|
||||||
|
android:id="@+id/gemtext_text_link"
|
||||||
|
android:textColor="@color/stroke"
|
||||||
|
android:textSize="@dimen/large_text_size"
|
||||||
|
android:clickable="true"
|
||||||
|
android:focusable="true"
|
||||||
|
android:drawableEnd="@drawable/vector_photo"
|
||||||
|
android:drawablePadding="4dp"
|
||||||
|
android:layout_marginLeft="@dimen/screen_margin"
|
||||||
|
android:layout_marginRight="@dimen/screen_margin"
|
||||||
|
android:padding="@dimen/accessibility_button_padding"
|
||||||
|
android:textAllCaps="false"
|
||||||
|
tools:text="an image"
|
||||||
|
android:backgroundTint="@color/accessibility_button_background"
|
||||||
|
android:textAlignment="viewStart"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content" />
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.AppCompatImageView
|
||||||
|
android:id="@+id/gemtext_inline_image"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginLeft="@dimen/screen_margin"
|
||||||
|
android:layout_marginRight="@dimen/screen_margin"
|
||||||
|
android:layout_marginTop="@dimen/default_margin"
|
||||||
|
android:layout_marginBottom="@dimen/default_margin"
|
||||||
|
android:background="#ffffff"
|
||||||
|
android:visibility="gone"
|
||||||
|
android:adjustViewBounds="true"
|
||||||
|
android:layout_below="@+id/gemtext_text_link"/>
|
||||||
|
</RelativeLayout>
|
|
@ -0,0 +1,26 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<RelativeLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.AppCompatButton
|
||||||
|
android:id="@+id/gemtext_text_link"
|
||||||
|
android:textColor="@color/stroke"
|
||||||
|
android:layout_marginLeft="@dimen/screen_margin"
|
||||||
|
android:layout_marginRight="@dimen/screen_margin"
|
||||||
|
android:layout_marginTop="@dimen/default_margin_small"
|
||||||
|
android:layout_marginBottom="@dimen/default_margin_small"
|
||||||
|
android:textSize="@dimen/large_text_size"
|
||||||
|
android:clickable="true"
|
||||||
|
android:focusable="true"
|
||||||
|
android:textAllCaps="false"
|
||||||
|
android:textAlignment="viewStart"
|
||||||
|
android:padding="@dimen/accessibility_button_padding"
|
||||||
|
android:backgroundTint="@color/accessibility_button_background"
|
||||||
|
android:drawablePadding="4dp"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content" />
|
||||||
|
|
||||||
|
</RelativeLayout>
|
|
@ -0,0 +1,18 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.appcompat.widget.AppCompatTextView
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:id="@+id/gemtext_text_monospace_textview"
|
||||||
|
android:textSize="@dimen/large_text_size"
|
||||||
|
android:textColor="@color/stroke"
|
||||||
|
android:layout_marginLeft="@dimen/screen_margin"
|
||||||
|
android:layout_marginRight="@dimen/screen_margin"
|
||||||
|
android:paddingLeft="@dimen/default_margin"
|
||||||
|
android:paddingRight="@dimen/default_margin"
|
||||||
|
android:paddingTop="@dimen/default_margin"
|
||||||
|
android:paddingBottom="@dimen/default_margin"
|
||||||
|
android:textStyle="italic"
|
||||||
|
android:background="@color/code_background"
|
||||||
|
android:textIsSelectable="true"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:lineHeight="@dimen/large_line_height"/>
|
|
@ -0,0 +1,12 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.appcompat.widget.AppCompatTextView
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:id="@+id/gemtext_text_textview"
|
||||||
|
android:textSize="@dimen/large_text_size"
|
||||||
|
android:layout_marginLeft="@dimen/screen_margin"
|
||||||
|
android:layout_marginRight="@dimen/screen_margin"
|
||||||
|
android:textColor="@color/stroke"
|
||||||
|
android:textIsSelectable="true"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:lineHeight="@dimen/large_line_height"/>
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue