Compare commits
167 Commits
Author | SHA1 | Date |
---|---|---|
Corewala | 326c20f5ff | |
Corewala | 867688a075 | |
Corewala | bedbc9fb98 | |
Corewala | df6c159c2c | |
Corewala | 93ef22a4f8 | |
Corewala | 0a47f391e5 | |
Corewala | 73afc7c684 | |
Corewala | 0fdffee966 | |
Corewala | da4375b832 | |
Corewala | 9f4d1c8027 | |
Corewala | 0df1501fd4 | |
Ariel Costas | 2bd546d7ed | |
Corewala | 2c3c1db96b | |
strooonger | 9038f49d40 | |
Corewala | eac1adb0cb | |
Corewala | eee109bb28 | |
Corewala | f89f41ae14 | |
Corewala | 26093144dd | |
Corewala | 47dd2722e1 | |
Corewala | b85de17c88 | |
Corewala | 53c1980fa6 | |
Corewala | 0ae42a214d | |
Corewala | 319b0b4d14 | |
Corewala | 13f21bc09b | |
Corewala | 79a3564569 | |
Corewala | 4a451ef5ca | |
Corewala | 467e3fc0b7 | |
Corewala | affa99e8f2 | |
Corewala | 4c94bd97e4 | |
Corewala | 679c3bd0be | |
Corewala | aa6dcdad91 | |
Corewala | 9d4386939a | |
Corewala | 001e7d3ffe | |
Corewala | 882617df29 | |
Corewala | ba901e49ba | |
Corewala | cf8efbc625 | |
Corewala | 09fc2a480a | |
Corewala | a47d003f59 | |
Corewala | f79d0bf5ff | |
Licaon_Kter | 011eb7487c | |
Corewala | bae12d17a8 | |
Corewala | f98756bebb | |
Corewala | e4cc13bb03 | |
Corewala | 3dee099dcf | |
Corewala | 132749016a | |
Corewala | a791521565 | |
Corewala | 8258c48bb0 | |
Corewala | c12702ef2f | |
Corewala | 28fa72f59e | |
Corewala | 13af2089d8 | |
Corewala | 2c24fa0c77 | |
Corewala | 0a59ac08cb | |
Corewala | c23b3efb70 | |
Corewala | 6d4dbbda94 | |
Corewala | 95ed0b815b | |
Corewala | 125c34c6d0 | |
Corewala | 1c3905b197 | |
Corewala | ee8ff30210 | |
Corewala | 45b50afd47 | |
Corewala | dd95af5e5b | |
Corewala | fbdb04bf31 | |
Corewala | 98d9af2871 | |
Corewala | 79a0d32bf1 | |
Corewala | 5ccdaeb816 | |
Corewala | f2d633e7e2 | |
Corewala | 05e2f64ebd | |
Corewala | 2db5aa74ca | |
Corewala | 7828f3ea7f | |
Corewala | bd8bbbc903 | |
Corewala | 8f8bb15455 | |
Corewala | 918deb4cdf | |
Corewala | cdcbdcdb2c | |
Corewala | 35352fa6f3 | |
Corewala | 0505e5d5bf | |
Corewala | 764cee3042 | |
Corewala | c0be77ef41 | |
Corewala | 60673c2fb3 | |
Corewala | d247b17999 | |
Corewala | eef63cd2e7 | |
Corewala | 8bb8c4f8f1 | |
Corewala | 30dcb45661 | |
Corewala | 589ab24a7e | |
Corewala | 1d2be092f1 | |
Corewala | d504df9cd3 | |
Corewala | 1ec24a38a5 | |
Corewala | d5ccf72ccf | |
Corewala | 3d32bc95d5 | |
Corewala | 5bb5fa7dcb | |
Corewala | 8cde96c971 | |
Corewala | 0b7e24e764 | |
Corewala | a7877cfc41 | |
Corewala | 684239c066 | |
Corewala | e10e78b9f5 | |
Corewala | 3df657d4d4 | |
Corewala | da37d8d684 | |
Corewala | 04e8cfc2c7 | |
Corewala | 84af7e4588 | |
Corewala | 980a1ac4d5 | |
Corewala | 0b97d239c4 | |
Corewala | 76145f80d5 | |
Corewala | 0d30671b4b | |
Corewala | c2cedab881 | |
Corewala | 815c6bdccf | |
Corewala | 5387f6635f | |
Corewala | 5e1e580f68 | |
Corewala | cf3402e91f | |
Corewala | 506976b25e | |
Corewala | b2f10454db | |
Corewala | da4702b1a0 | |
Corewala | 29fe06ba54 | |
Corewala | 5210d8484e | |
Corewala | b954e94d3b | |
Corewala | 2007e64550 | |
Corewala | 4808620743 | |
Corewala | 20846dad47 | |
Corewala | a519a522cf | |
Corewala | e46ea379c3 | |
Corewala | 55940914c0 | |
Corewala | 36952de39f | |
Corewala | fe957eb581 | |
Corewala | 0c1cb3a309 | |
Corewala | 55db823420 | |
Corewala | 93191e67c4 | |
Corewala | 64f3308e1b | |
Corewala | 88583d1f49 | |
Corewala | 02e27c7868 | |
Corewala | 15cc2d9a31 | |
Corewala | 75f42173c9 | |
Corewala | bfd2572cec | |
Corewala | 1542ec5539 | |
Corewala | fbf6c80282 | |
Corewala | 94074e4689 | |
Corewala | 82688f02b6 | |
Corewala | 494997c0ea | |
Corewala | 913bf12686 | |
Corewala | 95d6e92a04 | |
Corewala | 1275136433 | |
Corewala | 0981c23e37 | |
Corewala | ee664058bc | |
Corewala | e27526deaa | |
Corewala | d60c1a3b20 | |
Corewala | b2ef138402 | |
Corewala | 79056fe060 | |
Corewala | 01df294462 | |
Corewala | 8c7546f095 | |
Corewala | f54add615a | |
Corewala | 539f17e9be | |
Corewala | 676d64e4bc | |
Corewala | 508d1f39e6 | |
Corewala | 972ff95ecd | |
Corewala | 791fabca74 | |
Corewala | 3661c134c4 | |
Corewala | f0c85315a1 | |
Corewala | d999c683f0 | |
Corewala | 6dab6918c6 | |
Corewala | 70734e33d9 | |
Corewala | d7a2cdeb9f | |
Corewala | 8d70218d23 | |
Corewala | 48c7339d57 | |
Corewala | 4cd0e1977b | |
Corewala | a5ec6624c4 | |
Corewala | 8c0bfd04db | |
Corewala | a78d11bf5a | |
Corewala | 324d947330 | |
Corewala | f6f7997c69 | |
Corewala | 102377d8d3 | |
Corewala | c550a37581 |
|
@ -1,4 +1,6 @@
|
|||
.gradle
|
||||
.idea
|
||||
build
|
||||
release
|
||||
release
|
||||
local.properties
|
||||
app/local.properties
|
22
README.md
|
@ -8,26 +8,32 @@
|
|||
[![shields](https://img.shields.io/badge/Download-Here-orange?style=for-the-badge)](https://github.com/Corewala/Buran/releases/latest)
|
||||
[![shields](https://img.shields.io/badge/license-GPL-blue?style=for-the-badge)](https://github.com/Corewala/Buran/blob/master/LICENSE)
|
||||
|
||||
<a href="https://f-droid.org/packages/corewala.gemini.buran">
|
||||
<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png"
|
||||
alt="Get it on F-Droid"
|
||||
height="80">
|
||||
</a>
|
||||
|
||||
Buran is a simple Gemini protocol browser for Android.
|
||||
|
||||
### Todo list
|
||||
|
||||
- [ ] Utility for creating and managing client certificates
|
||||
- [ ] Keystore generator and catalog
|
||||
- [ ] Option to require password or biometric authentication
|
||||
- [X] Option to require password or biometric authentication
|
||||
- [ ] Color palette interface for picking background and accent colors
|
||||
- [x] Option to render links as buttons
|
||||
- [X] Option to render links as buttons
|
||||
- [X] Inline rendering of images
|
||||
- [ ] Page navigation feature
|
||||
- [ ] Update notifier
|
||||
|
||||
## Statement about Ariane
|
||||
|
||||
> I've seen some pretty cringe discussion in geminispace complaining about the decision by Öppen to close-source the Ariane browser, sometimes referencing this project as a more "stable" or "principled" project. Just to be 100% clear, I think that the license that Öppen used for Ariane 4 was actually much better than the one that Ariane 3 used, and I would have made the same decision if the EUPL was compatible with copyfarleft. Although I wish Ariane could continue to grow as an open-source project, I respect his decision to make Seren closed source. I only made this fork out of a legitimate desire to keep some fragment of this project public and an interest in learning Kotlin. Even though this fork exists, you should still consider buying a copy of Seren. He's definitely a better developer than me, and I don't want this project to be used as a rhetorical tool against him.
|
||||
- [X] Update notifier
|
||||
- [X] Attention guide mode
|
||||
- [ ] Simple A/B page switching system
|
||||
- [ ] Pass [Egsam test](https://github.com/pitr/egsam)
|
||||
- [X] Option to define an HTTPS gateway
|
||||
|
||||
## 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.
|
||||
Buran is based on the [Ariane source code](https://web.archive.org/web/20210920212507/https://codeberg.org/oppenlab/Ariane) (now [Seren](https://orllewin.neocities.org/seren/)), created by ÖLAB.
|
||||
|
||||
The font used in code blocks is [JetBrains Mono](https://www.jetbrains.com/lp/mono/), created by JetBrains.
|
||||
|
||||
|
|
|
@ -11,8 +11,8 @@ android {
|
|||
applicationId "corewala.gemini.buran"
|
||||
minSdkVersion 21
|
||||
targetSdkVersion 30
|
||||
versionCode 6
|
||||
versionName "v1.5"
|
||||
versionCode 13
|
||||
versionName "v1.12"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
|
@ -21,6 +21,7 @@ android {
|
|||
buildTypes {
|
||||
release {
|
||||
minifyEnabled true
|
||||
shrinkResources true
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
|
@ -56,6 +57,7 @@ dependencies {
|
|||
implementation 'androidx.appcompat:appcompat:1.2.0'
|
||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||
implementation 'com.google.android.material:material:1.3.0-rc01'
|
||||
implementation "androidx.biometric:biometric:1.1.0"
|
||||
|
||||
//ROOM DB
|
||||
def room_version = "2.2.6"
|
||||
|
|
|
@ -1,4 +0,0 @@
|
|||
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
|
|
@ -3,13 +3,15 @@
|
|||
package="corewala.buran">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<application
|
||||
android:name="corewala.buran.Buran"
|
||||
android:allowBackup="true"
|
||||
android:icon="@drawable/launcher"
|
||||
android:label="Buran"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@drawable/launcher"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme"
|
||||
|
|
|
@ -5,14 +5,13 @@ import android.app.Application
|
|||
class Buran: Application() {
|
||||
|
||||
companion object{
|
||||
const val DEFAULT_HOME_CAPSULE = "gemini://rawtext.club/~sloum/spacewalk.gmi"
|
||||
|
||||
const val FEATURE_CLIENT_CERTS = true
|
||||
const val DEFAULT_HOME_CAPSULE = ""
|
||||
const val DEFAULT_SEARCH_BASE = "gemini://tlgs.one/search?"
|
||||
|
||||
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 CLIENT_CERT_PASSWORD_SECRET_KEY_NAME = "client_cert_secret_key_name"
|
||||
const val PREF_KEY_USE_CUSTOM_TAB = "use_custom_tabs"
|
||||
}
|
||||
}
|
|
@ -4,7 +4,6 @@ import android.net.Uri
|
|||
import java.util.*
|
||||
|
||||
const val GEM_SCHEME = "gemini://"
|
||||
const val SEARCH_BASE = "gemini://tlgs.one/search?"
|
||||
|
||||
class OmniTerm(private val listener: Listener) {
|
||||
val history = ArrayList<OppenURI>()
|
||||
|
@ -15,8 +14,12 @@ class OmniTerm(private val listener: Listener) {
|
|||
* User input to the 'omni bar' - could be an address or a search term
|
||||
* @param term - User-inputted term
|
||||
*/
|
||||
fun input(term: String){
|
||||
fun input(term: String, searchbase: String?){
|
||||
when {
|
||||
term.contains(" ") -> {
|
||||
val encoded = Uri.encode(term)
|
||||
listener.request("$searchbase$encoded")
|
||||
}
|
||||
term.startsWith(GEM_SCHEME) && term != GEM_SCHEME -> {
|
||||
listener.request(term)
|
||||
return
|
||||
|
@ -26,14 +29,14 @@ class OmniTerm(private val listener: Listener) {
|
|||
}
|
||||
else -> {
|
||||
val encoded = Uri.encode(term)
|
||||
listener.request("$SEARCH_BASE$encoded")
|
||||
listener.request("$searchbase$encoded")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun search(term: String){
|
||||
fun search(term: String, searchbase: String?){
|
||||
val encoded = Uri.encode(term)
|
||||
listener.request("$SEARCH_BASE$encoded")
|
||||
listener.request("$searchbase$encoded")
|
||||
}
|
||||
|
||||
|
||||
|
@ -51,21 +54,27 @@ class OmniTerm(private val listener: Listener) {
|
|||
*/
|
||||
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")
|
||||
link.startsWith("http://") or link.startsWith("https://") -> {
|
||||
uri.set(link)
|
||||
}
|
||||
link.contains(":") -> listener.openExternal(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")
|
||||
val address = uri.toString().replace("//", "/").replace(":/", "://")
|
||||
|
||||
if(invokeListener) listener.request(address)
|
||||
println("OmniTerm resolved address: $address")
|
||||
}
|
||||
|
||||
fun traverse(address: String): String {
|
||||
return OppenURI(address).traverse().toString()
|
||||
fun getGlobalUri(reference: String): String {
|
||||
return when {
|
||||
reference.contains(":") -> reference
|
||||
reference.startsWith("//") -> "gemini:$reference"
|
||||
else -> uri.resolve(reference, false)
|
||||
}
|
||||
}
|
||||
|
||||
fun reset(){
|
||||
|
@ -81,7 +90,11 @@ class OmniTerm(private val listener: Listener) {
|
|||
}
|
||||
|
||||
fun getCurrent(): String {
|
||||
return history.last().toString()
|
||||
return if(history.size > 0){
|
||||
history.last().toString()
|
||||
}else{
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
fun canGoBack(): Boolean {
|
||||
|
@ -93,8 +106,12 @@ class OmniTerm(private val listener: Listener) {
|
|||
return history.last().toString()
|
||||
}
|
||||
|
||||
fun clearCache() {
|
||||
history.clear()
|
||||
}
|
||||
|
||||
interface Listener{
|
||||
fun request(address: String)
|
||||
fun openBrowser(address: String)
|
||||
fun openExternal(address: String)
|
||||
}
|
||||
}
|
|
@ -1,9 +1,12 @@
|
|||
package corewala.buran
|
||||
|
||||
const val SCHEME = "gemini://"
|
||||
import corewala.toURI
|
||||
|
||||
const val GEMSCHEME = "gemini://"
|
||||
const val TRAVERSE = "../"
|
||||
const val SOLIDUS = "/"
|
||||
const val DIREND = "/"
|
||||
const val QUERY = "?"
|
||||
|
||||
/**
|
||||
*
|
||||
|
@ -15,68 +18,65 @@ class OppenURI constructor(private var ouri: String) {
|
|||
constructor(): this("")
|
||||
|
||||
var host: String = ""
|
||||
var scheme: String = ""
|
||||
|
||||
init {
|
||||
extractHost()
|
||||
if(ouri.isNotEmpty()){
|
||||
host = ouri.toURI().host
|
||||
scheme = ouri.toURI().scheme
|
||||
}
|
||||
}
|
||||
|
||||
fun set(ouri: String){
|
||||
this.ouri = ouri
|
||||
extractHost()
|
||||
if(ouri.isNotEmpty()){
|
||||
host = ouri.toURI().host
|
||||
scheme = ouri.toURI().scheme
|
||||
}
|
||||
}
|
||||
|
||||
fun resolve(reference: String) {
|
||||
if(ouri == "$SCHEME$host") ouri = "$ouri/"
|
||||
return when {
|
||||
reference.startsWith(SCHEME) -> set(reference)
|
||||
reference.startsWith(SOLIDUS) -> ouri = "$SCHEME$host$reference"
|
||||
fun resolve(reference: String, persistent: Boolean): String{
|
||||
if(ouri == "$GEMSCHEME$host") ouri = "$ouri/"
|
||||
var resolvedUri = ""
|
||||
when {
|
||||
reference.startsWith(GEMSCHEME) -> set(reference)
|
||||
reference.startsWith(SOLIDUS) -> resolvedUri = "$scheme://$host$reference"
|
||||
reference.startsWith(TRAVERSE) -> {
|
||||
if(!ouri.endsWith(DIREND)) ouri = ouri.removeFile()
|
||||
if(!ouri.endsWith(DIREND)) resolvedUri = ouri.removeFile()
|
||||
val traversalCount = reference.split(TRAVERSE).size - 1
|
||||
ouri = traverse(traversalCount) + reference.replace(TRAVERSE, "")
|
||||
resolvedUri = traverse(traversalCount) + reference.replace(TRAVERSE, "")
|
||||
}
|
||||
reference.startsWith(QUERY) -> {
|
||||
resolvedUri = if(reference.contains(QUERY)){
|
||||
ouri.substringBefore(QUERY) + reference
|
||||
}else{
|
||||
ouri + reference
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
ouri = when {
|
||||
ouri.endsWith(DIREND) -> "${ouri}$reference"
|
||||
resolvedUri = when {
|
||||
ouri.endsWith(DIREND) -> {
|
||||
"${ouri}$reference"
|
||||
}
|
||||
else -> "${ouri.substring(0, ouri.lastIndexOf("/"))}/$reference"
|
||||
}
|
||||
}
|
||||
}
|
||||
if(persistent){
|
||||
ouri = resolvedUri
|
||||
}
|
||||
return resolvedUri
|
||||
}
|
||||
|
||||
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
|
||||
fun resolve(reference: String): String{
|
||||
return resolve(reference, true)
|
||||
}
|
||||
|
||||
private fun traverse(count: Int): String{
|
||||
val path = ouri.removePrefix("$SCHEME$host")
|
||||
val path = ouri.removePrefix("$GEMSCHEME$host")
|
||||
val segments = path.split(SOLIDUS).filter { it.isNotEmpty() }
|
||||
val segmentCount = segments.size
|
||||
var nouri = "$SCHEME$host"
|
||||
var nouri = "$GEMSCHEME$host"
|
||||
|
||||
segments.forEachIndexed{ index, segment ->
|
||||
if(index < segmentCount - count){
|
||||
|
@ -88,15 +88,6 @@ class OppenURI constructor(private var ouri: String) {
|
|||
|
||||
}
|
||||
|
||||
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
|
||||
|
|
|
@ -8,17 +8,16 @@ 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 Redirect(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 ClientCertRequired(val uri: URI, val header: GeminiResponse.Header): GemState()
|
||||
data class ClientCertError(val header: GeminiResponse.Header): GemState()
|
||||
|
||||
object Blank: GemState()
|
||||
}
|
|
@ -40,7 +40,6 @@ class BuranHistory(private val db: BuranAbstractDatabase): HistoryDatasource {
|
|||
|
||||
override fun add(uri: Uri, onAdded: () -> Unit) {
|
||||
if(!uri.toString().startsWith("gemini://")){
|
||||
onAdded
|
||||
return
|
||||
}
|
||||
GlobalScope.launch(Dispatchers.IO){
|
||||
|
|
|
@ -6,11 +6,10 @@ 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 request(address: String, forceDownload: Boolean, clientCertPassword: String?, alternativeRequest: String?, onUpdate: (state: GemState) -> Unit)
|
||||
fun isRequesting(): Boolean
|
||||
fun cancel()
|
||||
fun canGoBack(): Boolean
|
||||
fun goBack(onUpdate: (state: GemState) -> Unit)
|
||||
|
||||
companion object{
|
||||
fun factory(context: Context, history: BuranHistory): Datasource {
|
||||
return GeminiDatasource(context, history)
|
||||
|
|
|
@ -2,16 +2,15 @@ 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.OppenURI
|
||||
import corewala.buran.io.GemState
|
||||
import corewala.buran.io.database.history.BuranHistory
|
||||
import corewala.buran.io.keymanager.BuranKeyManager
|
||||
import corewala.toURI
|
||||
import corewala.toUri
|
||||
import java.io.*
|
||||
import java.lang.IllegalStateException
|
||||
import java.net.ConnectException
|
||||
import java.net.URI
|
||||
import java.net.UnknownHostException
|
||||
|
@ -20,7 +19,6 @@ import javax.net.ssl.*
|
|||
|
||||
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
|
||||
|
||||
|
@ -32,61 +30,78 @@ class GeminiDatasource(private val context: Context, val history: BuranHistory):
|
|||
|
||||
private var socketFactory: SSLSocketFactory? = null
|
||||
|
||||
override fun request(address: String, forceDownload: Boolean, onUpdate: (state: GemState) -> Unit) {
|
||||
this.forceDownload = forceDownload
|
||||
request(address, onUpdate)
|
||||
}
|
||||
private var currentRequestAddress: String? = null
|
||||
|
||||
override fun request(address: String, forceDownload: Boolean, clientCertPassword: String?, alternativeRequest: String?, onUpdate: (state: GemState) -> Unit){
|
||||
this.forceDownload = forceDownload
|
||||
|
||||
override fun request(address: String, onUpdate: (state: GemState) -> Unit) {
|
||||
this.onUpdate = onUpdate
|
||||
|
||||
val uri = URI.create(address)
|
||||
|
||||
onUpdate(GemState.Requesting(uri))
|
||||
|
||||
if(address.startsWith("gemini://")){
|
||||
currentRequestAddress = address
|
||||
}
|
||||
|
||||
GlobalScope.launch {
|
||||
geminiRequest(uri, onUpdate)
|
||||
geminiRequest(uri, onUpdate, clientCertPassword, alternativeRequest)
|
||||
}
|
||||
}
|
||||
|
||||
private fun initSSLFactory(protocol: String){
|
||||
override fun isRequesting(): Boolean{
|
||||
return !currentRequestAddress.isNullOrEmpty()
|
||||
}
|
||||
|
||||
override fun cancel(){
|
||||
currentRequestAddress = null
|
||||
}
|
||||
|
||||
private fun initSSLFactory(protocol: String, clientCertPassword: String?){
|
||||
val sslContext = when (protocol) {
|
||||
"TLS_ALL" -> SSLContext.getInstance("TLS")
|
||||
else -> SSLContext.getInstance(protocol)
|
||||
}
|
||||
|
||||
sslContext.init(buranKeyManager.getFactory()?.keyManagers, DummyTrustManager.get(), null)
|
||||
sslContext.init(buranKeyManager.getFactory(clientCertPassword)?.keyManagers, DummyTrustManager.get(), null)
|
||||
socketFactory = sslContext.socketFactory
|
||||
}
|
||||
|
||||
private fun geminiRequest(uri: URI, onUpdate: (state: GemState) -> Unit){
|
||||
private fun geminiRequest(uri: URI, onUpdate: (state: GemState) -> Unit, clientCertPassword: String?, alternativeRequest: String?){
|
||||
val 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!!)
|
||||
initSSLFactory(protocol, clientCertPassword)
|
||||
|
||||
val port = if(uri.port != -1){
|
||||
uri.port
|
||||
}else{
|
||||
1965
|
||||
}
|
||||
|
||||
val socket: SSLSocket?
|
||||
try {
|
||||
socket = socketFactory?.createSocket(uri.host, 1965) as SSLSocket
|
||||
socket = socketFactory?.createSocket(uri.host, port) as SSLSocket
|
||||
|
||||
println("Buran socket handshake with ${uri.host}")
|
||||
socket.startHandshake()
|
||||
}catch (uhe: UnknownHostException){
|
||||
println("Buran socket error, unknown host: $uhe")
|
||||
onUpdate(GemState.ResponseUnknownHost(uri))
|
||||
if(currentRequestAddress == uri.toString()) {
|
||||
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())))
|
||||
if(currentRequestAddress == uri.toString()) {
|
||||
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())))
|
||||
if(currentRequestAddress == uri.toString()) {
|
||||
println("Buran socket error, ssl handshake exception: $she")
|
||||
onUpdate(GemState.ResponseError(GeminiResponse.Header(-2, she.message ?: she.toString())))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -95,7 +110,12 @@ class GeminiDatasource(private val context: Context, val history: BuranHistory):
|
|||
val bufferedWriter = BufferedWriter(outputStreamWriter)
|
||||
val outWriter = PrintWriter(bufferedWriter)
|
||||
|
||||
val requestEntity = uri.toString() + "\r\n"
|
||||
val requestEntity = if(alternativeRequest.isNullOrEmpty()){
|
||||
uri.toString()
|
||||
}else{
|
||||
alternativeRequest
|
||||
} + "\r\n"
|
||||
|
||||
println("Buran socket requesting $requestEntity")
|
||||
outWriter.print(requestEntity)
|
||||
outWriter.flush()
|
||||
|
@ -113,30 +133,38 @@ class GeminiDatasource(private val context: Context, val history: BuranHistory):
|
|||
val bufferedReader = BufferedReader(headerInputReader)
|
||||
val headerLine = bufferedReader.readLine()
|
||||
|
||||
println("Buran: response header: $headerLine")
|
||||
println("Buran response header: $headerLine")
|
||||
|
||||
if(headerLine == null){
|
||||
onUpdate(GemState.ResponseError(GeminiResponse.Header(-2, "Server did not respond with a Gemini header: $uri")))
|
||||
if(currentRequestAddress == uri.toString()){
|
||||
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))
|
||||
if(currentRequestAddress == uri.toString()){
|
||||
currentRequestAddress = null
|
||||
when {
|
||||
header.code == GeminiResponse.INPUT -> onUpdate(GemState.ResponseInput(uri, header))
|
||||
header.code == GeminiResponse.REDIRECT -> onUpdate(GemState.Redirect(uri, header))
|
||||
header.code == GeminiResponse.CLIENT_CERTIFICATE_REQUIRED -> onUpdate(GemState.ClientCertRequired(uri, header))
|
||||
header.code != GeminiResponse.SUCCESS -> onUpdate(GemState.ResponseError(header))
|
||||
header.meta.startsWith("text/gemini") -> getGemtext(bufferedReader, requestEntity.trim().toURI(), 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}else{
|
||||
println("Buran dropped response from $uri: request cancelled or superseded")
|
||||
}
|
||||
|
||||
//Close input
|
||||
|
@ -159,10 +187,6 @@ class GeminiDatasource(private val context: Context, val history: BuranHistory):
|
|||
|
||||
val processed = GemtextHelper.findCodeBlocks(lines)
|
||||
|
||||
when {
|
||||
!uri.toString().startsWith("gemini://") -> throw IllegalStateException("Not a Gemini Uri")
|
||||
}
|
||||
|
||||
updateHistory(uri)
|
||||
onUpdate(GemState.ResponseGemtext(uri, header, processed))
|
||||
}
|
||||
|
@ -186,12 +210,10 @@ class GeminiDatasource(private val context: Context, val history: BuranHistory):
|
|||
|
||||
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 charset = "ABCDEFGHIJKLMNOPQRSTUVWXTZabcdefghiklmnopqrstuvwxyz0123456789"
|
||||
val filename = (1..12)
|
||||
.map{charset.random()}
|
||||
.joinToString("")
|
||||
|
||||
val host = uri.host.replace(".", "_")
|
||||
val cacheName = "${host}_$filename"
|
||||
|
@ -223,11 +245,6 @@ class GeminiDatasource(private val context: Context, val history: BuranHistory):
|
|||
|
||||
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,107 @@
|
|||
package corewala.buran.io.keymanager
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import corewala.buran.Buran
|
||||
import corewala.buran.R
|
||||
import java.nio.charset.Charset
|
||||
import java.security.KeyStore
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
|
||||
data class EncryptedData(val ciphertext: ByteArray, val initializationVector: ByteArray)
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.P)
|
||||
class BuranBiometricManager {
|
||||
|
||||
private lateinit var biometricPrompt: BiometricPrompt
|
||||
private lateinit var promptInfo: BiometricPrompt.PromptInfo
|
||||
|
||||
fun createBiometricPrompt(context: Context, fragment: Fragment?, activity: FragmentActivity?, callback: BiometricPrompt.AuthenticationCallback){
|
||||
val executor = ContextCompat.getMainExecutor(context)
|
||||
if(fragment != null){
|
||||
biometricPrompt = BiometricPrompt(fragment, executor, callback)
|
||||
}else if(activity != null){
|
||||
biometricPrompt = BiometricPrompt(activity, executor, callback)
|
||||
}
|
||||
promptInfo = BiometricPrompt.PromptInfo.Builder()
|
||||
.setConfirmationRequired(false)
|
||||
.setTitle(context.getString(R.string.confirm_your_identity))
|
||||
.setSubtitle(context.getString(R.string.use_biometric_unlock))
|
||||
.setNegativeButtonText(context.getString(R.string.cancel).toUpperCase())
|
||||
.build()
|
||||
}
|
||||
|
||||
fun authenticateToEncryptData() {
|
||||
val cipher = getCipher()
|
||||
val secretKey = getSecretKey(Buran.CLIENT_CERT_PASSWORD_SECRET_KEY_NAME)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, secretKey)
|
||||
biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
|
||||
}
|
||||
|
||||
fun authenticateToDecryptData(initializationVector: ByteArray) {
|
||||
val cipher = getCipher()
|
||||
val secretKey = getSecretKey(Buran.CLIENT_CERT_PASSWORD_SECRET_KEY_NAME)
|
||||
cipher.init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(128, initializationVector))
|
||||
biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
|
||||
}
|
||||
|
||||
// Allows ByteArrays to be stored in prefs as strings. Possibly the most horrifying function I've ever written.
|
||||
fun decodeByteArray(encodedByteArray: String): ByteArray{
|
||||
val byteList = encodedByteArray.substring(1, encodedByteArray.length - 1).split(", ")
|
||||
var decodedByteArray = byteArrayOf()
|
||||
for(byte in byteList){
|
||||
decodedByteArray += byte.toInt().toByte()
|
||||
}
|
||||
println(decodedByteArray.contentToString())
|
||||
return decodedByteArray
|
||||
}
|
||||
|
||||
fun encryptData(plaintext: String, cipher: Cipher): EncryptedData {
|
||||
val ciphertext = cipher.doFinal(plaintext.toByteArray(Charset.forName("UTF-8")))
|
||||
return EncryptedData(ciphertext,cipher.iv)
|
||||
}
|
||||
|
||||
fun decryptData(ciphertext: ByteArray, cipher: Cipher): String {
|
||||
val plaintext = cipher.doFinal(ciphertext)
|
||||
return String(plaintext, Charset.forName("UTF-8"))
|
||||
}
|
||||
|
||||
private fun getCipher(): Cipher {
|
||||
val transformation = "${KeyProperties.KEY_ALGORITHM_AES}/${KeyProperties.BLOCK_MODE_GCM}/${KeyProperties.ENCRYPTION_PADDING_NONE}"
|
||||
return Cipher.getInstance(transformation)
|
||||
}
|
||||
|
||||
private fun getSecretKey(keyName: String): SecretKey {
|
||||
val androidKeystore = "AndroidKeyStore"
|
||||
val keyStore = KeyStore.getInstance(androidKeystore)
|
||||
keyStore.load(null)
|
||||
keyStore.getKey(keyName, null)?.let { return it as SecretKey }
|
||||
|
||||
val keyGenParams = KeyGenParameterSpec.Builder(
|
||||
keyName,
|
||||
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
|
||||
).apply {
|
||||
setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
setKeySize(256)
|
||||
setUserAuthenticationRequired(true)
|
||||
}.build()
|
||||
|
||||
val keyGenerator = KeyGenerator.getInstance(
|
||||
KeyProperties.KEY_ALGORITHM_AES,
|
||||
androidKeystore
|
||||
)
|
||||
keyGenerator.init(keyGenParams)
|
||||
return keyGenerator.generateKey()
|
||||
}
|
||||
}
|
|
@ -5,7 +5,6 @@ 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
|
||||
|
@ -19,23 +18,20 @@ class BuranKeyManager(val context: Context, val onKeyError: (error: String) -> U
|
|||
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 -> {
|
||||
fun getFactory(clientCertPassword: String?): KeyManagerFactory? {
|
||||
return when { !clientCertPassword.isNullOrEmpty() -> {
|
||||
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())
|
||||
keyStore.load(it, clientCertPassword.toCharArray())
|
||||
val keyManagerFactory: KeyManagerFactory =
|
||||
KeyManagerFactory.getInstance("X509")
|
||||
keyManagerFactory.init(keyStore, password?.toCharArray())
|
||||
keyManagerFactory.init(keyStore, clientCertPassword.toCharArray())
|
||||
return@use keyManagerFactory
|
||||
} catch (ioe: IOException) {
|
||||
onKeyError("${ioe.message}")
|
||||
|
@ -53,15 +49,4 @@ class BuranKeyManager(val context: Context, val onKeyError: (error: String) -> U
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
//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,91 @@
|
|||
package corewala.buran.io.update
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Context.DOWNLOAD_SERVICE
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import androidx.core.content.FileProvider
|
||||
import corewala.buran.BuildConfig
|
||||
import corewala.buran.R
|
||||
import java.io.File
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
|
||||
class BuranUpdates {
|
||||
|
||||
fun getLatestVersion(): String {
|
||||
var latestVersion = BuildConfig.VERSION_NAME
|
||||
|
||||
val updateCheckThread = Thread {
|
||||
val url = "https://github.com/Corewala/Buran/releases/latest"
|
||||
|
||||
val con = URL(url).openConnection() as HttpURLConnection
|
||||
con.connect()
|
||||
con.getInputStream()
|
||||
|
||||
latestVersion = con.getURL().toString().drop(47)
|
||||
}
|
||||
|
||||
updateCheckThread.start()
|
||||
updateCheckThread.join()
|
||||
println("Latest version: $latestVersion")
|
||||
|
||||
return latestVersion
|
||||
}
|
||||
|
||||
fun installUpdate(context: Context, latestVersion: String){
|
||||
val updateUrl = "https://github.com/Corewala/Buran/releases/download/$latestVersion/Buran-$latestVersion.apk"
|
||||
var updateDestination = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS).toString() + "/Buran.apk"
|
||||
val fileUri = Uri.parse("file://$updateDestination")
|
||||
val packageFile = File(updateDestination)
|
||||
if(packageFile.exists()){
|
||||
packageFile.delete()
|
||||
}
|
||||
|
||||
val downloadManager = context.getSystemService(DOWNLOAD_SERVICE) as DownloadManager
|
||||
val downloadUri = Uri.parse(updateUrl)
|
||||
val request = DownloadManager.Request(downloadUri)
|
||||
|
||||
request.setTitle(context.getString(R.string.app_name))
|
||||
request.setDescription("")
|
||||
request.setDestinationUri(fileUri)
|
||||
request.setMimeType("application/vnd.android.package-archive")
|
||||
|
||||
val contentUri = FileProvider.getUriForFile(
|
||||
context,
|
||||
BuildConfig.APPLICATION_ID + ".provider",
|
||||
packageFile
|
||||
)
|
||||
|
||||
val updateDownloadReceiver: BroadcastReceiver = object : BroadcastReceiver(){
|
||||
override fun onReceive(context: Context, intent: Intent){
|
||||
val intent = Intent(Intent.ACTION_VIEW)
|
||||
|
||||
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.N){
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
intent.putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true)
|
||||
intent.data = contentUri
|
||||
} else {
|
||||
val intent = Intent(Intent.ACTION_VIEW)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
intent.setDataAndType(
|
||||
fileUri,
|
||||
"application/vnd.android.package-archive"
|
||||
)
|
||||
}
|
||||
println("Installing update")
|
||||
context.startActivity(intent)
|
||||
context.unregisterReceiver(this)
|
||||
}
|
||||
}
|
||||
context.registerReceiver(updateDownloadReceiver, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE))
|
||||
downloadManager.enqueue(request)
|
||||
}
|
||||
}
|
|
@ -6,19 +6,30 @@ import android.content.Intent
|
|||
import android.content.SharedPreferences
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.text.InputType
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.widget.EditText
|
||||
import android.widget.Toast
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.biometric.BiometricPrompt
|
||||
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 corewala.buran.BuildConfig
|
||||
import corewala.buran.Buran
|
||||
import corewala.buran.OmniTerm
|
||||
import corewala.buran.R
|
||||
|
@ -28,14 +39,14 @@ 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.io.keymanager.BuranBiometricManager
|
||||
import corewala.buran.io.update.BuranUpdates
|
||||
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_adapter.*
|
||||
import corewala.buran.ui.modals_menus.about.AboutDialog
|
||||
import corewala.buran.ui.gemtext_adapter.AbstractGemtextAdapter
|
||||
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
|
||||
|
@ -43,6 +54,7 @@ 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
|
||||
|
@ -53,6 +65,7 @@ class GemActivity : AppCompatActivity() {
|
|||
lateinit var prefs: SharedPreferences
|
||||
private var inSearch = false
|
||||
private lateinit var bookmarkDatasource: BookmarksDatasource
|
||||
private lateinit var db: BuranDatabase
|
||||
private var bookmarksDialog: BookmarksDialog? = null
|
||||
|
||||
private val model by viewModels<GemViewModel>()
|
||||
|
@ -60,23 +73,31 @@ class GemActivity : AppCompatActivity() {
|
|||
|
||||
private val omniTerm = OmniTerm(object : OmniTerm.Listener {
|
||||
override fun request(address: String) {
|
||||
loadingView(true)
|
||||
model.request(address)
|
||||
gemRequest(address)
|
||||
}
|
||||
|
||||
override fun openBrowser(address: String) = openWebLink(address)
|
||||
override fun openExternal(address: String) = openExternalLink(address)
|
||||
})
|
||||
|
||||
lateinit var adapter: AbstractGemtextAdapter
|
||||
private var certPassword: String? = null
|
||||
|
||||
private val onLink: (link: URI, longTap: Boolean, adapterPosition: Int) -> Unit = { uri, longTap, position: Int ->
|
||||
private var proxiedAddress: String? = null
|
||||
|
||||
private var previousPosition: Int = 0
|
||||
|
||||
private var initialised: Boolean = false
|
||||
|
||||
private var goingBack: Boolean = false
|
||||
|
||||
private lateinit var adapter: AbstractGemtextAdapter
|
||||
|
||||
private lateinit var home: String
|
||||
|
||||
private lateinit var searchBase: String
|
||||
|
||||
private val onLink: (link: URI, longTap: Boolean, adapterPosition: Int) -> Unit = { uri, longTap, _: Int ->
|
||||
if(longTap){
|
||||
var globalURI: String
|
||||
if(!uri.toString().contains("//")){
|
||||
globalURI = (omniTerm.getCurrent() + uri.toString()).replace("%2F", "/").replace("//", "/").replace("gemini:/", "gemini://")
|
||||
} else {
|
||||
globalURI = uri.toString()
|
||||
}
|
||||
val globalURI = omniTerm.getGlobalUri(uri.toString())
|
||||
Intent().apply {
|
||||
action = Intent.ACTION_SEND
|
||||
putExtra(Intent.EXTRA_TEXT, globalURI)
|
||||
|
@ -89,19 +110,26 @@ class GemActivity : AppCompatActivity() {
|
|||
binding.addressEdit.hint = getString(R.string.main_input_hint)
|
||||
inSearch = false
|
||||
}
|
||||
|
||||
omniTerm.navigation(uri.toString())
|
||||
}
|
||||
}
|
||||
|
||||
private val inlineImage: (link: URI, adapterPosition: Int) -> Unit = { uri, position: Int ->
|
||||
|
||||
omniTerm.imageAddress(uri.toString())
|
||||
omniTerm.uri.let{
|
||||
model.requestInlineImage(URI.create(it.toString())){ imageUri ->
|
||||
imageUri?.let{
|
||||
runOnUiThread {
|
||||
loadImage(position, imageUri)
|
||||
loadingView(false)
|
||||
if(getInternetStatus()){
|
||||
omniTerm.imageAddress(uri.toString())
|
||||
val clientCertPassword = if(isHostSigned(uri)){
|
||||
certPassword
|
||||
}else{
|
||||
null
|
||||
}
|
||||
omniTerm.uri.let{
|
||||
model.requestInlineImage(URI.create(it.toString()), clientCertPassword){ imageUri ->
|
||||
imageUri?.let{
|
||||
runOnUiThread {
|
||||
loadImage(position, imageUri)
|
||||
loadingView(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -115,9 +143,6 @@ class GemActivity : AppCompatActivity() {
|
|||
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
|
||||
|
@ -128,25 +153,62 @@ class GemActivity : AppCompatActivity() {
|
|||
|
||||
prefs = PreferenceManager.getDefaultSharedPreferences(this)
|
||||
|
||||
when (prefs.getString("theme", "theme_FollowSystem")) {
|
||||
"theme_FollowSystem" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
|
||||
"theme_Light" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
|
||||
"theme_Dark" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
|
||||
}
|
||||
|
||||
adapter = AbstractGemtextAdapter.getAdapter(onLink, inlineImage)
|
||||
|
||||
binding.gemtextRecycler.adapter = adapter
|
||||
|
||||
model.initialise(
|
||||
home = prefs.getString(
|
||||
home = prefs.getString(
|
||||
"home_capsule",
|
||||
Buran.DEFAULT_HOME_CAPSULE
|
||||
) ?: Buran.DEFAULT_HOME_CAPSULE
|
||||
|
||||
if(
|
||||
!home.startsWith("gemini://")
|
||||
or home.contains(" ")
|
||||
or !home.contains(".")
|
||||
){
|
||||
home = ""
|
||||
}
|
||||
|
||||
searchBase = prefs.getString(
|
||||
"search_base",
|
||||
Buran.DEFAULT_SEARCH_BASE
|
||||
) ?: Buran.DEFAULT_SEARCH_BASE
|
||||
|
||||
if(
|
||||
!searchBase.startsWith("gemini://")
|
||||
or searchBase.contains(" ")
|
||||
or !searchBase.contains(".")
|
||||
or !searchBase.endsWith("?")
|
||||
){
|
||||
searchBase = Buran.DEFAULT_SEARCH_BASE
|
||||
}
|
||||
|
||||
if(getInternetStatus()){
|
||||
initialise()
|
||||
}else{
|
||||
loadingView(false)
|
||||
val home = PreferenceManager.getDefaultSharedPreferences(this).getString(
|
||||
"home_capsule",
|
||||
Buran.DEFAULT_HOME_CAPSULE
|
||||
) ?: Buran.DEFAULT_HOME_CAPSULE,
|
||||
gemini = Datasource.factory(this, db.history()),
|
||||
db = db,
|
||||
onState = this::handleState
|
||||
)
|
||||
)
|
||||
val title = "# ${this.getString(R.string.no_internet)}"
|
||||
val text = this.getString(R.string.retry)
|
||||
omniTerm.set(home!!)
|
||||
adapter.render(listOf(title, text))
|
||||
binding.addressEdit.inputType = InputType.TYPE_NULL
|
||||
}
|
||||
|
||||
binding.addressEdit.setOnEditorActionListener { _, actionId, _ ->
|
||||
when (actionId) {
|
||||
EditorInfo.IME_ACTION_GO -> {
|
||||
omniTerm.input(binding.addressEdit.text.toString().trim())
|
||||
binding.addressEdit.hideKeyboard()
|
||||
omniTerm.input(binding.addressEdit.text.toString().trim(), searchBase)
|
||||
binding.addressEdit.clearFocus()
|
||||
return@setOnEditorActionListener true
|
||||
}
|
||||
|
@ -155,14 +217,12 @@ class GemActivity : AppCompatActivity() {
|
|||
}
|
||||
|
||||
binding.addressEdit.setOnClickListener {
|
||||
binding.addressEdit.clearFocus()
|
||||
binding.addressEdit.showKeyboard()
|
||||
binding.addressEdit.requestFocus()
|
||||
}
|
||||
|
||||
binding.addressEdit.setOnFocusChangeListener { v, hasFocus ->
|
||||
binding.addressEdit.setOnFocusChangeListener { _, hasFocus ->
|
||||
|
||||
var addressPaddingRight = resources.getDimensionPixelSize(R.dimen.def_address_right_margin)
|
||||
val addressPaddingRight = resources.getDimensionPixelSize(R.dimen.def_address_right_margin)
|
||||
|
||||
if(!hasFocus) {
|
||||
binding.addressEdit.hideKeyboard()
|
||||
|
@ -185,6 +245,25 @@ class GemActivity : AppCompatActivity() {
|
|||
binding.addressEdit.requestFocus()
|
||||
inSearch = true
|
||||
}
|
||||
R.id.overflow_menu_sign -> {
|
||||
if (prefs.getBoolean("use_biometrics", false) and certPassword.isNullOrEmpty()) {
|
||||
biometricSecureRequest(binding.addressEdit.text.toString())
|
||||
}else if(certPassword.isNullOrEmpty()){
|
||||
if (certPassword.isNullOrEmpty()) {
|
||||
certPassword = prefs.getString(
|
||||
Buran.PREF_KEY_CLIENT_CERT_PASSWORD,
|
||||
null
|
||||
)
|
||||
}
|
||||
refresh()
|
||||
Toast.makeText(this, this.getString(R.string.cert_loaded), Toast.LENGTH_SHORT).show()
|
||||
}else{
|
||||
certPassword = null
|
||||
refresh()
|
||||
Toast.makeText(this, this.getString(R.string.cert_unloaded), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
R.id.overflow_menu_bookmark -> {
|
||||
val name = adapter.inferTitle()
|
||||
BookmarkDialog(
|
||||
|
@ -198,7 +277,7 @@ class GemActivity : AppCompatActivity() {
|
|||
}
|
||||
R.id.overflow_menu_bookmarks -> {
|
||||
bookmarksDialog = BookmarksDialog(this, bookmarkDatasource) { bookmark ->
|
||||
model.request(bookmark.uri.toString())
|
||||
gemRequest(bookmark.uri.toString())
|
||||
}
|
||||
bookmarksDialog?.show()
|
||||
}
|
||||
|
@ -212,29 +291,41 @@ class GemActivity : AppCompatActivity() {
|
|||
}
|
||||
R.id.overflow_menu_history -> HistoryDialog.show(
|
||||
this,
|
||||
db.history()
|
||||
db.history(),
|
||||
omniTerm
|
||||
) { historyAddress ->
|
||||
model.request(historyAddress)
|
||||
gemRequest(historyAddress)
|
||||
}
|
||||
R.id.overflow_menu_about -> AboutDialog.show(this)
|
||||
R.id.overflow_menu_about -> gemRequest("")
|
||||
R.id.overflow_menu_settings -> {
|
||||
startActivity(Intent(this, SettingsActivity::class.java))
|
||||
}
|
||||
}
|
||||
}
|
||||
if(!prefs.getString(Buran.PREF_KEY_CLIENT_CERT_URI, null).isNullOrEmpty()){
|
||||
OverflowPopup.setItemVisibility(R.id.overflow_menu_sign, true)
|
||||
if(certPassword.isNullOrEmpty()){
|
||||
OverflowPopup.setItemTitle(R.id.overflow_menu_sign, getString(R.string.load_cert))
|
||||
}else{
|
||||
OverflowPopup.setItemTitle(R.id.overflow_menu_sign, getString(R.string.unload_cert))
|
||||
}
|
||||
}else{
|
||||
OverflowPopup.setItemVisibility(R.id.overflow_menu_sign, false)
|
||||
}
|
||||
}
|
||||
|
||||
binding.home.setOnClickListener {
|
||||
val home = PreferenceManager.getDefaultSharedPreferences(this).getString(
|
||||
"home_capsule",
|
||||
Buran.DEFAULT_HOME_CAPSULE
|
||||
)
|
||||
omniTerm.history.clear()
|
||||
model.request(home!!)
|
||||
gemRequest(home, false)
|
||||
}
|
||||
|
||||
binding.pullToRefresh.setOnRefreshListener {
|
||||
refresh()
|
||||
if(getInternetStatus()){
|
||||
refresh()
|
||||
}else{
|
||||
binding.pullToRefresh.isRefreshing = false
|
||||
Snackbar.make(binding.root, getString(R.string.no_internet), Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
checkIntentExtras(intent)
|
||||
|
@ -244,13 +335,40 @@ class GemActivity : AppCompatActivity() {
|
|||
omniTerm.getCurrent().run{
|
||||
binding.addressEdit.setText(this)
|
||||
focusEnd()
|
||||
model.request(this)
|
||||
gemRequest(this)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
home = prefs.getString(
|
||||
"home_capsule",
|
||||
Buran.DEFAULT_HOME_CAPSULE
|
||||
) ?: Buran.DEFAULT_HOME_CAPSULE
|
||||
|
||||
if(
|
||||
!home.startsWith("gemini://")
|
||||
or home.contains(" ")
|
||||
or !home.contains(".")
|
||||
){
|
||||
home = ""
|
||||
}
|
||||
|
||||
searchBase = prefs.getString(
|
||||
"search_base",
|
||||
Buran.DEFAULT_SEARCH_BASE
|
||||
) ?: Buran.DEFAULT_SEARCH_BASE
|
||||
|
||||
if(
|
||||
!searchBase.startsWith("gemini://")
|
||||
or searchBase.contains(" ")
|
||||
or !searchBase.contains(".")
|
||||
or !searchBase.endsWith("?")
|
||||
){
|
||||
searchBase = Buran.DEFAULT_SEARCH_BASE
|
||||
}
|
||||
|
||||
when {
|
||||
prefs.contains("background_colour") -> {
|
||||
when (val backgroundColor = prefs.getString("background_colour", "#XXXXXX")) {
|
||||
|
@ -260,22 +378,6 @@ class GemActivity : AppCompatActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
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 showInlineIcons = prefs.getBoolean(
|
||||
"show_inline_icons",
|
||||
true
|
||||
|
@ -284,10 +386,16 @@ class GemActivity : AppCompatActivity() {
|
|||
|
||||
val showLinkButtons = prefs.getBoolean(
|
||||
"show_link_buttons",
|
||||
true
|
||||
false
|
||||
)
|
||||
adapter.linkButtons(showLinkButtons)
|
||||
|
||||
val useAttentionGuides = prefs.getBoolean(
|
||||
"use_attention_guides",
|
||||
false
|
||||
)
|
||||
adapter.attentionGuides(useAttentionGuides)
|
||||
|
||||
|
||||
val showInlineImages = prefs.getBoolean(
|
||||
"show_inline_images",
|
||||
|
@ -295,11 +403,32 @@ class GemActivity : AppCompatActivity() {
|
|||
)
|
||||
adapter.inlineImages(showInlineImages)
|
||||
|
||||
model.invalidateDatasource()
|
||||
if(getInternetStatus()){
|
||||
model.invalidateDatasource()
|
||||
}
|
||||
}
|
||||
|
||||
private fun hideClientCertShield(){
|
||||
binding.addressEdit.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0)
|
||||
private fun updateClientCertIcon(){
|
||||
if (!prefs.getString(
|
||||
Buran.PREF_KEY_CLIENT_CERT_URI,
|
||||
null
|
||||
).isNullOrEmpty()){
|
||||
if(certPassword.isNullOrEmpty()){
|
||||
binding.addressEdit.setCompoundDrawablesWithIntrinsicBounds(
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
)
|
||||
}else{
|
||||
binding.addressEdit.setCompoundDrawablesWithIntrinsicBounds(
|
||||
R.drawable.vector_client_cert,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleState(state: GemState) {
|
||||
|
@ -307,23 +436,88 @@ class GemActivity : AppCompatActivity() {
|
|||
|
||||
when (state) {
|
||||
is GemState.AppQuery -> runOnUiThread { showAlert("App backdoor/query not implemented yet") }
|
||||
|
||||
is GemState.ResponseInput -> runOnUiThread {
|
||||
val builder = AlertDialog.Builder(this, R.style.AppDialogTheme)
|
||||
val inflater: LayoutInflater = layoutInflater
|
||||
val dialogLayout: View = inflater.inflate(R.layout.dialog_input_query, null)
|
||||
val editText: EditText = dialogLayout.findViewById(R.id.query_input)
|
||||
editText.requestFocus()
|
||||
editText.showKeyboard()
|
||||
loadingView(false)
|
||||
InputDialog.show(this, state) { queryAddress ->
|
||||
model.request(queryAddress)
|
||||
builder
|
||||
.setTitle(state.header.meta)
|
||||
.setPositiveButton(getString(R.string.confirm).toUpperCase()){ _, _ ->
|
||||
gemRequest("${state.uri}?${Uri.encode(editText.text.toString())}")
|
||||
editText.hideKeyboard()
|
||||
}
|
||||
.setNegativeButton(getString(R.string.cancel).toUpperCase()){ _, _ ->
|
||||
editText.hideKeyboard()
|
||||
}
|
||||
.setView(dialogLayout)
|
||||
.show()
|
||||
}
|
||||
|
||||
is GemState.Redirect -> {
|
||||
omniTerm.set(state.uri.toString())
|
||||
gemRequest(omniTerm.getGlobalUri(state.header.meta))
|
||||
}
|
||||
|
||||
is GemState.ClientCertRequired -> runOnUiThread {
|
||||
loadingView(false)
|
||||
val builder = AlertDialog.Builder(this, R.style.AppDialogTheme)
|
||||
builder
|
||||
.setTitle(getString(R.string.client_certificate_required))
|
||||
.setMessage(state.header.meta)
|
||||
|
||||
if(!prefs.getString(Buran.PREF_KEY_CLIENT_CERT_URI, null).isNullOrEmpty()){
|
||||
builder
|
||||
.setPositiveButton(getString(R.string.use_client_certificate).toUpperCase()) { _, _ ->
|
||||
if(prefs.getBoolean("use_biometrics", false) and certPassword.isNullOrEmpty()){
|
||||
biometricSecureRequest(state.uri.toString())
|
||||
}else{
|
||||
if(certPassword.isNullOrEmpty()){
|
||||
certPassword = prefs.getString(
|
||||
Buran.PREF_KEY_CLIENT_CERT_PASSWORD,
|
||||
null
|
||||
)
|
||||
}
|
||||
gemRequest(state.uri.toString(), true)
|
||||
Toast.makeText(this, this.getString(R.string.cert_loaded), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
.setNegativeButton(getString(R.string.cancel).toUpperCase()) { _, _ -> }
|
||||
.show()
|
||||
}else{
|
||||
builder
|
||||
.setNegativeButton(getString(R.string.close).toUpperCase()) { _, _ -> }
|
||||
.show()
|
||||
}
|
||||
}
|
||||
is GemState.Requesting -> loadingView(true)
|
||||
is GemState.NotGeminiRequest -> externalProtocol(state)
|
||||
|
||||
is GemState.Requesting -> {
|
||||
loadingView(true)
|
||||
}
|
||||
is GemState.NotGeminiRequest -> {
|
||||
externalProtocol(state.uri)
|
||||
}
|
||||
is GemState.ResponseError -> {
|
||||
omniTerm.reset()
|
||||
showAlert("${GeminiResponse.getCodeString(state.header.code)}:\n\n${state.header.meta}")
|
||||
}
|
||||
is GemState.ClientCertError -> {
|
||||
hideClientCertShield()
|
||||
certPassword = null
|
||||
updateClientCertIcon()
|
||||
showAlert("${GeminiResponse.getCodeString(state.header.code)}:\n\n${state.header.meta}")
|
||||
}
|
||||
is GemState.ResponseGemtext -> renderGemtext(state)
|
||||
is GemState.ResponseGemtext -> {
|
||||
if(state.uri.scheme != "gemini"){
|
||||
Snackbar.make(binding.root, getString(R.string.proxied_content), Snackbar.LENGTH_LONG).setAction(getString(R.string.open_original)) {
|
||||
externalProtocol(state.uri)
|
||||
}.show()
|
||||
}
|
||||
renderGemtext(state)
|
||||
}
|
||||
is GemState.ResponseText -> renderText(state)
|
||||
is GemState.ResponseImage -> renderImage(state)
|
||||
is GemState.ResponseBinary -> renderBinary(state)
|
||||
|
@ -337,28 +531,38 @@ class GemActivity : AppCompatActivity() {
|
|||
|
||||
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)
|
||||
if(getInternetStatus()) {
|
||||
val clientCertPassword = if(isHostSigned(state.uri)){
|
||||
certPassword
|
||||
}else{
|
||||
null
|
||||
}
|
||||
.setNegativeButton(getString(R.string.cancel)) { _, _ -> }
|
||||
.show()
|
||||
AlertDialog.Builder(this, R.style.AppDialogTheme)
|
||||
.setTitle("$download: ${state.header.meta}")
|
||||
.setMessage("${state.uri}")
|
||||
.setPositiveButton(getString(R.string.download).toUpperCase()) { _, _ ->
|
||||
loadingView(true)
|
||||
model.requestBinaryDownload(state.uri, clientCertPassword, null)
|
||||
}
|
||||
.setNegativeButton(getString(R.string.cancel).toUpperCase()) { _, _ -> }
|
||||
.show()
|
||||
}else{
|
||||
Snackbar.make(binding.root, getString(R.string.no_internet), Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
is GemState.ResponseUnknownHost -> {
|
||||
omniTerm.reset()
|
||||
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 TLGS instead?")
|
||||
.setPositiveButton(getString(R.string.search)) { _, _ ->
|
||||
.setTitle(getString(R.string.unknown_host))
|
||||
.setMessage("${getString(R.string.unknown_host)}: ${state.uri}\n\n${getString(R.string.search_instead)}")
|
||||
.setPositiveButton(getString(R.string.search).toUpperCase()) { _, _ ->
|
||||
loadingView(true)
|
||||
omniTerm.search(state.uri.toString())
|
||||
omniTerm.search(state.uri.toString(), searchBase)
|
||||
}
|
||||
.setNegativeButton(getString(R.string.cancel)) { _, _ -> }
|
||||
.setNegativeButton(getString(R.string.cancel).toUpperCase()) { _, _ -> }
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
@ -384,11 +588,51 @@ class GemActivity : AppCompatActivity() {
|
|||
val uri = intent.data
|
||||
if(uri != null){
|
||||
binding.addressEdit.setText(uri.toString())
|
||||
model.request(uri.toString())
|
||||
gemRequest(uri.toString())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private fun biometricSecureRequest(address: String){
|
||||
val biometricManager = BuranBiometricManager()
|
||||
|
||||
val callback = object : BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||
super.onAuthenticationError(errorCode, errString)
|
||||
println("Authentication error: $errorCode: $errString")
|
||||
}
|
||||
override fun onAuthenticationFailed() {
|
||||
super.onAuthenticationFailed()
|
||||
println("Authentication failed")
|
||||
}
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
super.onAuthenticationSucceeded(result)
|
||||
println("Authentication succeeded")
|
||||
|
||||
val ciphertext = biometricManager.decodeByteArray(
|
||||
prefs.getString(
|
||||
"password_ciphertext",
|
||||
null
|
||||
)!!
|
||||
)
|
||||
|
||||
certPassword = biometricManager.decryptData(ciphertext, result.cryptoObject?.cipher!!)
|
||||
gemRequest(address, true)
|
||||
Toast.makeText(applicationContext, applicationContext.getString(R.string.cert_loaded), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
val initializationVector = biometricManager.decodeByteArray(
|
||||
prefs.getString(
|
||||
"password_init_vector",
|
||||
null
|
||||
)!!
|
||||
)
|
||||
|
||||
biometricManager.createBiometricPrompt(this, null, this, callback)
|
||||
biometricManager.authenticateToDecryptData(initializationVector)
|
||||
}
|
||||
|
||||
private fun showAlert(message: String) = runOnUiThread{
|
||||
loadingView(false)
|
||||
|
||||
|
@ -396,7 +640,7 @@ class GemActivity : AppCompatActivity() {
|
|||
AlertDialog.Builder(this)
|
||||
.setTitle(getString(R.string.error))
|
||||
.setMessage(message)
|
||||
.setPositiveButton("OK"){ _, _ ->
|
||||
.setPositiveButton(getString(R.string.close).toUpperCase()){ _, _ ->
|
||||
|
||||
}
|
||||
.show()
|
||||
|
@ -405,15 +649,15 @@ class GemActivity : AppCompatActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
private fun externalProtocol(state: GemState.NotGeminiRequest) = runOnUiThread {
|
||||
private fun externalProtocol(uri: URI) = runOnUiThread {
|
||||
loadingView(false)
|
||||
val uri = state.uri.toString()
|
||||
val uri = uri.toString()
|
||||
|
||||
when {
|
||||
(uri.startsWith("http://") || uri.startsWith("https://")) -> openWebLink(uri)
|
||||
(uri.startsWith("http://") || uri.startsWith("https://")) -> openExternalLink(uri)
|
||||
else -> {
|
||||
val viewIntent = Intent(Intent.ACTION_VIEW)
|
||||
viewIntent.data = Uri.parse(state.uri.toString())
|
||||
viewIntent.data = Uri.parse(uri.toString())
|
||||
|
||||
try {
|
||||
startActivity(viewIntent)
|
||||
|
@ -421,7 +665,7 @@ class GemActivity : AppCompatActivity() {
|
|||
showAlert(
|
||||
String.format(
|
||||
getString(R.string.no_app_installed_that_can_open),
|
||||
state.uri
|
||||
uri
|
||||
)
|
||||
)
|
||||
}
|
||||
|
@ -429,14 +673,24 @@ class GemActivity : AppCompatActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
private fun openWebLink(address: String){
|
||||
private fun openExternalLink(address: String){
|
||||
if(PreferenceManager.getDefaultSharedPreferences(this).getBoolean(
|
||||
Buran.PREF_KEY_USE_CUSTOM_TAB,
|
||||
true
|
||||
)) {
|
||||
)or !address.startsWith("http")) {
|
||||
val builder = CustomTabsIntent.Builder()
|
||||
val intent = builder.build()
|
||||
intent.launchUrl(this, Uri.parse(address))
|
||||
|
||||
try {
|
||||
intent.launchUrl(this, Uri.parse(address))
|
||||
}catch (e: ActivityNotFoundException){
|
||||
showAlert(
|
||||
String.format(
|
||||
getString(R.string.no_app_installed_that_can_open),
|
||||
address
|
||||
)
|
||||
)
|
||||
}
|
||||
}else{
|
||||
val viewIntent = Intent(Intent.ACTION_VIEW)
|
||||
viewIntent.data = Uri.parse(address)
|
||||
|
@ -447,18 +701,29 @@ class GemActivity : AppCompatActivity() {
|
|||
private fun renderGemtext(state: GemState.ResponseGemtext) = runOnUiThread {
|
||||
loadingView(false)
|
||||
|
||||
omniTerm.set(state.uri.toString())
|
||||
omniTerm.set(proxiedAddress ?: 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())
|
||||
|
||||
if(!goingBack){
|
||||
previousPosition = (binding.gemtextRecycler.layoutManager as LinearLayoutManager).findLastVisibleItemPosition()
|
||||
}
|
||||
|
||||
adapter.render(state.lines)
|
||||
|
||||
//Scroll to top
|
||||
binding.gemtextRecycler.post {
|
||||
binding.gemtextRecycler.scrollToPosition(0)
|
||||
//Scroll to correct position
|
||||
if(goingBack){
|
||||
println("Returning to previous position: $previousPosition")
|
||||
binding.gemtextRecycler.scrollToPosition(previousPosition)
|
||||
previousPosition = 0
|
||||
goingBack = false
|
||||
}else{
|
||||
binding.gemtextRecycler.post {
|
||||
binding.gemtextRecycler.scrollToPosition(0)
|
||||
}
|
||||
}
|
||||
|
||||
focusEnd()
|
||||
|
@ -499,7 +764,7 @@ class GemActivity : AppCompatActivity() {
|
|||
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 ->
|
||||
data?.data?.let{ uri ->
|
||||
val cachedFile = when {
|
||||
imageState != null -> File(imageState!!.cacheUri.path ?: "")
|
||||
binaryState != null -> File(binaryState!!.cacheUri.path ?: "")
|
||||
|
@ -553,9 +818,151 @@ class GemActivity : AppCompatActivity() {
|
|||
if(visible) binding.appBar.setExpanded(true)
|
||||
}
|
||||
|
||||
private fun getInternetStatus(): Boolean {
|
||||
val connectivityManager = this.getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q){
|
||||
val capabilities = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork)
|
||||
if(capabilities != null){
|
||||
when {
|
||||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> { return true }
|
||||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> { return true }
|
||||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> { return true }
|
||||
}
|
||||
}
|
||||
}else{
|
||||
val activeNetworkInfo = connectivityManager.activeNetworkInfo
|
||||
if(activeNetworkInfo != null && activeNetworkInfo.isConnected){
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun initialise(){
|
||||
db = BuranDatabase(applicationContext)
|
||||
bookmarkDatasource = db.bookmarks()
|
||||
|
||||
if(intent.data == null){
|
||||
model.initialise(
|
||||
home = home,
|
||||
gemini = Datasource.factory(this, db.history()),
|
||||
db = db,
|
||||
onState = this::handleState
|
||||
)
|
||||
if(home.isEmpty()){
|
||||
loadLocalHome()
|
||||
}
|
||||
}else{
|
||||
model.initialise(
|
||||
home = intent.data.toString(),
|
||||
gemini = Datasource.factory(this, db.history()),
|
||||
db = db,
|
||||
onState = this::handleState
|
||||
)
|
||||
}
|
||||
|
||||
if(PreferenceManager.getDefaultSharedPreferences(this).getBoolean(
|
||||
"check_for_updates",
|
||||
false
|
||||
)){
|
||||
val updates = BuranUpdates()
|
||||
val latestVersion = updates.getLatestVersion()
|
||||
|
||||
if (latestVersion == BuildConfig.VERSION_NAME){
|
||||
println("No new version available")
|
||||
} else {
|
||||
println("New version available")
|
||||
|
||||
Snackbar.make(binding.root, getString(R.string.new_version_available), Snackbar.LENGTH_LONG).setAction(getString(R.string.update)) {
|
||||
updates.installUpdate(this, latestVersion)
|
||||
}.show()
|
||||
}
|
||||
}
|
||||
|
||||
initialised = true
|
||||
}
|
||||
|
||||
private fun loadLocalHome(){
|
||||
loadingView(false)
|
||||
binding.pullToRefresh.isRefreshing = false
|
||||
binding.addressEdit.text?.clear()
|
||||
|
||||
val title = "# ${getString(R.string.app_name)}"
|
||||
val sourceLink = "=> https://github.com/Corewala/Buran ${getString(R.string.source)}"
|
||||
adapter.render(listOf(
|
||||
title,
|
||||
"",
|
||||
getString(R.string.about_body),
|
||||
"",
|
||||
getString(R.string.about_ariane_source),
|
||||
"",
|
||||
getString(R.string.about_font),
|
||||
"",
|
||||
getString(R.string.about_glyphs),
|
||||
"",
|
||||
sourceLink,
|
||||
getString(R.string.copyright)
|
||||
))
|
||||
omniTerm.set("")
|
||||
}
|
||||
|
||||
private fun isHostSigned(uri: URI): Boolean{
|
||||
if((uri.host != omniTerm.getCurrent().toURI().host) && !certPassword.isNullOrEmpty()) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun gemRequest(address: String, sign: Boolean?){
|
||||
if(sign == null){
|
||||
if(!isHostSigned(address.toURI())) certPassword = null
|
||||
}else if(!sign){
|
||||
certPassword = null
|
||||
}
|
||||
updateClientCertIcon()
|
||||
|
||||
if(address.startsWith("http://") or address.startsWith("https://")){
|
||||
val httpProxy = prefs.getString("http_proxy", null) ?: ""
|
||||
|
||||
if(
|
||||
httpProxy.isNullOrEmpty()
|
||||
or !httpProxy.startsWith("gemini://")
|
||||
or httpProxy.contains(" ")
|
||||
or !httpProxy.contains(".")
|
||||
){
|
||||
openExternalLink(address)
|
||||
}else{
|
||||
model.request(httpProxy, certPassword, address)
|
||||
}
|
||||
}
|
||||
|
||||
if(getInternetStatus()){
|
||||
if(initialised){
|
||||
if(address.isEmpty()){
|
||||
loadLocalHome()
|
||||
}else{
|
||||
model.request(address, certPassword, null)
|
||||
}
|
||||
}else{
|
||||
initialise()
|
||||
}
|
||||
}else{
|
||||
Snackbar.make(binding.root, getString(R.string.no_internet), Snackbar.LENGTH_LONG).show()
|
||||
loadingView(false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun gemRequest(address: String){
|
||||
gemRequest(address, null)
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
if (omniTerm.canGoBack()){
|
||||
model.request(omniTerm.goBack())
|
||||
if(model.isRequesting()){
|
||||
model.cancel()
|
||||
loadingView(false)
|
||||
}else if(omniTerm.canGoBack()){
|
||||
goingBack = true
|
||||
gemRequest(omniTerm.goBack())
|
||||
}else{
|
||||
println("Buran history is empty - exiting")
|
||||
super.onBackPressed()
|
||||
|
@ -578,7 +985,7 @@ class GemActivity : AppCompatActivity() {
|
|||
savedInstanceState.getString("uri")?.run {
|
||||
omniTerm.set(this)
|
||||
binding.addressEdit.setText(this)
|
||||
model.request(this)
|
||||
gemRequest(this)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -19,24 +19,34 @@ class GemViewModel: ViewModel() {
|
|||
this.db = db
|
||||
this.onState = onState
|
||||
|
||||
request(home)
|
||||
if(home.startsWith("gemini://") and !home.contains(" ")){
|
||||
request(home, null, null)
|
||||
}
|
||||
}
|
||||
|
||||
fun request(address: String) {
|
||||
gemini.request(address){ state ->
|
||||
fun request(address: String, clientCertPassword: String?, alternativeRequest: String?) {
|
||||
gemini.request(address, false, clientCertPassword, alternativeRequest){ state ->
|
||||
onState(state)
|
||||
}
|
||||
}
|
||||
|
||||
fun requestBinaryDownload(uri: URI) {
|
||||
gemini.request(uri.toString(), true){ state ->
|
||||
fun isRequesting(): Boolean{
|
||||
return gemini.isRequesting()
|
||||
}
|
||||
|
||||
fun cancel(){
|
||||
gemini.cancel()
|
||||
}
|
||||
|
||||
fun requestBinaryDownload(uri: URI, clientCertPassword: String?, alternativeRequest: String?) {
|
||||
gemini.request(uri.toString(), true, clientCertPassword, alternativeRequest){ state ->
|
||||
onState(state)
|
||||
}
|
||||
}
|
||||
|
||||
//todo - same action as above... refactor
|
||||
fun requestInlineImage(uri: URI, onImageReady: (cacheUri: Uri?) -> Unit){
|
||||
gemini.request(uri.toString()){ state ->
|
||||
fun requestInlineImage(uri: URI, clientCertPassword: String?, onImageReady: (cacheUri: Uri?) -> Unit){
|
||||
gemini.request(uri.toString(), false, clientCertPassword, null){ state ->
|
||||
when (state) {
|
||||
is GemState.ResponseImage -> onImageReady(state.cacheUri)
|
||||
else -> onState(state)
|
||||
|
|
|
@ -10,8 +10,8 @@ abstract class AbstractGemtextAdapter(
|
|||
): RecyclerView.Adapter<GmiViewHolder>() {
|
||||
|
||||
var showInlineIcons: Boolean = false
|
||||
var hideCodeBlocks: Boolean = false
|
||||
var showLinkButtons: Boolean = false
|
||||
var useAttentionGuides: Boolean = false
|
||||
var showInlineImages: Boolean = false
|
||||
|
||||
abstract fun render(lines: List<String>)
|
||||
|
@ -19,7 +19,7 @@ abstract class AbstractGemtextAdapter(
|
|||
abstract fun inlineIcons(visible: Boolean)
|
||||
abstract fun inlineImages(visible: Boolean)
|
||||
abstract fun linkButtons(visible: Boolean)
|
||||
|
||||
abstract fun attentionGuides(enabled: Boolean)
|
||||
abstract fun inferTitle(): String?
|
||||
|
||||
companion object{
|
||||
|
|
|
@ -2,17 +2,19 @@ package corewala.buran.ui.gemtext_adapter
|
|||
|
||||
import android.annotation.SuppressLint
|
||||
import android.net.Uri
|
||||
import android.text.SpannableStringBuilder
|
||||
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 androidx.core.text.bold
|
||||
import corewala.buran.R
|
||||
import corewala.endsWithImage
|
||||
import corewala.visible
|
||||
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_quote.view.*
|
||||
import kotlinx.android.synthetic.main.gemtext_text.view.*
|
||||
import java.net.URI
|
||||
|
||||
class GemtextAdapter(
|
||||
|
@ -81,39 +83,49 @@ class GemtextAdapter(
|
|||
val line = lines[position]
|
||||
|
||||
when(holder){
|
||||
is GmiViewHolder.Text -> holder.itemView.gemtext_text_textview.text = line
|
||||
is GmiViewHolder.Text -> {
|
||||
when {
|
||||
useAttentionGuides -> holder.itemView.gemtext_text_textview.text = getAttentionGuideText(line)
|
||||
else -> 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)
|
||||
}
|
||||
}
|
||||
is GmiViewHolder.Quote -> holder.itemView.gemtext_text_monospace_textview.text = line.substring(1).trim()
|
||||
is GmiViewHolder.Quote -> {
|
||||
when {
|
||||
useAttentionGuides -> holder.itemView.gemtext_quote_textview.text = getAttentionGuideText(line.substring(1).trim())
|
||||
else -> holder.itemView.gemtext_quote_textview.text = line.substring(1).trim()
|
||||
}
|
||||
}
|
||||
is GmiViewHolder.H1 -> {
|
||||
when {
|
||||
line.length > 2 -> holder.itemView.gemtext_text_textview.text = line.substring(2).trim()
|
||||
line.length > 2 -> holder.itemView.gemtext_text_textview.text = line.substring(1).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()
|
||||
line.length > 3 -> holder.itemView.gemtext_text_textview.text = line.substring(2).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()
|
||||
line.length > 4 -> holder.itemView.gemtext_text_textview.text = line.substring(3).trim()
|
||||
else -> holder.itemView.gemtext_text_textview.text = ""
|
||||
}
|
||||
}
|
||||
is GmiViewHolder.ListItem -> holder.itemView.gemtext_text_textview.text = "• ${line.substring(1)}".trim()
|
||||
is GmiViewHolder.ListItem -> {
|
||||
when {
|
||||
useAttentionGuides -> holder.itemView.gemtext_text_textview.text = getAttentionGuideText("• ${line.substring(1)}".trim())
|
||||
else -> 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]
|
||||
|
@ -189,11 +201,11 @@ class GemtextAdapter(
|
|||
holder.itemView.gemtext_link_button.visible(true)
|
||||
holder.itemView.gemtext_link_button.text = displayText
|
||||
} else -> {
|
||||
holder.itemView.gemtext_link_button.visible(false)
|
||||
holder.itemView.gemtext_text_link.visible(true)
|
||||
holder.itemView.gemtext_text_link.text = displayText
|
||||
holder.itemView.gemtext_text_link.paint.isUnderlineText = true
|
||||
}
|
||||
holder.itemView.gemtext_link_button.visible(false)
|
||||
holder.itemView.gemtext_text_link.visible(true)
|
||||
holder.itemView.gemtext_text_link.text = displayText
|
||||
holder.itemView.gemtext_text_link.paint.isUnderlineText = true
|
||||
}
|
||||
}
|
||||
|
||||
holder.itemView.gemtext_text_link.setOnClickListener {
|
||||
|
@ -270,6 +282,49 @@ class GemtextAdapter(
|
|||
return URI.create(linkParts.first())
|
||||
}
|
||||
|
||||
private fun getAttentionGuideText(text: String): SpannableStringBuilder {
|
||||
val wordList = text.split(" ")
|
||||
|
||||
val attentionGuideText = SpannableStringBuilder()
|
||||
for(word in wordList){
|
||||
val wordComponents = word.split("-")
|
||||
|
||||
for(component in wordComponents) {
|
||||
val joiner = if((wordComponents.size > 1) and (wordComponents.indexOf(component) != wordComponents.size - 1)){
|
||||
"-"
|
||||
}else{
|
||||
" "
|
||||
}
|
||||
if (component.length > 1) {
|
||||
if (component.first().isLetterOrDigit()) {
|
||||
val index = component.length / 2
|
||||
attentionGuideText
|
||||
.bold { append(component.substring(0, index)) }
|
||||
.append("${component.substring(index)}$joiner")
|
||||
} else {
|
||||
var offset = 1
|
||||
|
||||
if (component.length - offset > 1) {
|
||||
while ((component.length - offset > 1) and !component.substring(offset).first().isLetterOrDigit()) {
|
||||
offset += 1
|
||||
}
|
||||
val index = (component.length - offset) / 2
|
||||
attentionGuideText
|
||||
.append(component.substring(0, offset))
|
||||
.bold { append(component.substring(offset, index + offset)) }
|
||||
.append("${component.substring(index + offset)}$joiner")
|
||||
}else{
|
||||
attentionGuideText.append("$component$joiner")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
attentionGuideText.append("$component$joiner")
|
||||
}
|
||||
}
|
||||
}
|
||||
return attentionGuideText
|
||||
}
|
||||
|
||||
override fun inferTitle(): String? {
|
||||
lines.forEach { line ->
|
||||
if(line.startsWith("#")) return line.replace("#", "").trim()
|
||||
|
@ -293,6 +348,11 @@ class GemtextAdapter(
|
|||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun attentionGuides(enabled: Boolean){
|
||||
this.useAttentionGuides = enabled
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun inlineImages(visible: Boolean){
|
||||
this.showInlineImages = visible
|
||||
notifyDataSetChanged()
|
||||
|
|
|
@ -1,45 +0,0 @@
|
|||
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 kotlinx.android.synthetic.main.dialog_content_text.view.*
|
||||
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.about_toolbar.setNavigationIcon(R.drawable.vector_close)
|
||||
view.about_toolbar.setNavigationOnClickListener {
|
||||
dialog.dismiss()
|
||||
}
|
||||
|
||||
view.source_button.setOnClickListener {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW).apply {
|
||||
data = Uri.parse("https://github.com/Corewala/Buran")
|
||||
})
|
||||
}
|
||||
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
}
|
|
@ -8,13 +8,13 @@ import android.widget.Toast
|
|||
import androidx.appcompat.app.AppCompatDialog
|
||||
import androidx.core.view.forEach
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import corewala.buran.OmniTerm
|
||||
import kotlinx.android.synthetic.main.dialog_history.view.*
|
||||
import corewala.buran.R
|
||||
import corewala.buran.io.database.history.BuranHistory
|
||||
import kotlinx.android.synthetic.main.dialog_bookmarks.view.*
|
||||
|
||||
object HistoryDialog {
|
||||
fun show(context: Context, history: BuranHistory, onHistoryItem: (address: String) -> Unit){
|
||||
fun show(context: Context, history: BuranHistory, omniTerm: OmniTerm, onHistoryItem: (address: String) -> Unit){
|
||||
|
||||
val dialog = AppCompatDialog(context, R.style.AppTheme)
|
||||
|
||||
|
@ -37,6 +37,7 @@ object HistoryDialog {
|
|||
}
|
||||
}
|
||||
R.id.menu_action_clear_runtime_cache -> {
|
||||
omniTerm.clearCache()
|
||||
Toast.makeText(context, context.getString(R.string.runtime_cache_cleared), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
else -> {
|
||||
|
|
|
@ -1,33 +0,0 @@
|
|||
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()
|
||||
}
|
||||
}
|
|
@ -16,9 +16,11 @@ import corewala.buran.R
|
|||
|
||||
object OverflowPopup {
|
||||
|
||||
lateinit var popup: PopupMenu
|
||||
|
||||
fun show(view: View?, onMenuOption: (menuId: Int) -> Unit){
|
||||
if(view != null) {
|
||||
val popup = PopupMenu(view.context, view)
|
||||
popup = PopupMenu(view.context, view)
|
||||
val inflater: MenuInflater = popup.menuInflater
|
||||
inflater.inflate(R.menu.overflow_menu, popup.menu)
|
||||
popup.setOnMenuItemClickListener { menuItem ->
|
||||
|
@ -31,6 +33,14 @@ object OverflowPopup {
|
|||
}
|
||||
}
|
||||
|
||||
fun setItemTitle(id: Int, title: String){
|
||||
popup.menu.findItem(id).title = title
|
||||
}
|
||||
|
||||
fun setItemVisibility(id: Int, visible: Boolean){
|
||||
popup.menu.findItem(id).isVisible = visible
|
||||
}
|
||||
|
||||
fun insertMenuItemIcons(context: Context, popupMenu: PopupMenu) {
|
||||
val menu: Menu = popupMenu.menu
|
||||
if (hasIcon(menu)) {
|
||||
|
|
|
@ -4,17 +4,20 @@ import android.app.Activity.RESULT_OK
|
|||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.OpenableColumns
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.preference.*
|
||||
import corewala.buran.Buran
|
||||
import corewala.buran.R
|
||||
|
||||
import corewala.buran.io.keymanager.BuranBiometricManager
|
||||
|
||||
const val PREFS_SET_CLIENT_CERT_REQ = 20
|
||||
|
||||
|
@ -23,7 +26,6 @@ class SettingsFragment: PreferenceFragmentCompat(), Preference.OnPreferenceChang
|
|||
lateinit var prefs: SharedPreferences
|
||||
|
||||
private lateinit var clientCertPref: Preference
|
||||
private lateinit var useClientCertPreference: SwitchPreferenceCompat
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
|
||||
|
@ -49,14 +51,35 @@ class SettingsFragment: PreferenceFragmentCompat(), Preference.OnPreferenceChang
|
|||
val homecapsule = preferenceManager.sharedPreferences.getString(
|
||||
"home_capsule",
|
||||
Buran.DEFAULT_HOME_CAPSULE
|
||||
)
|
||||
)?.trim()
|
||||
|
||||
homePreference.summary = if(homecapsule.isNullOrEmpty()){
|
||||
context.getString(R.string.no_home_capsule_set)
|
||||
}else if(
|
||||
!homecapsule.startsWith("gemini://")
|
||||
or homecapsule.contains(" ")
|
||||
or !homecapsule.contains(".")
|
||||
){
|
||||
context.getString(R.string.not_valid_address)
|
||||
}else{
|
||||
homecapsule
|
||||
}
|
||||
|
||||
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()
|
||||
val newHomecapsule = newValue.toString().trim()
|
||||
homePreference.summary = if(newHomecapsule.isNullOrEmpty()){
|
||||
context.getString(R.string.no_home_capsule_set)
|
||||
}else if(
|
||||
!newHomecapsule.startsWith("gemini://")
|
||||
or newHomecapsule.contains(" ")
|
||||
or !newHomecapsule.contains(".")
|
||||
){
|
||||
context.getString(R.string.not_valid_address)
|
||||
}else{
|
||||
newHomecapsule
|
||||
}
|
||||
true
|
||||
}
|
||||
homePreference.setOnBindEditTextListener{ editText ->
|
||||
|
@ -65,8 +88,72 @@ class SettingsFragment: PreferenceFragmentCompat(), Preference.OnPreferenceChang
|
|||
}
|
||||
appCategory.addPreference(homePreference)
|
||||
|
||||
//Home - Certificates
|
||||
buildClientCertificateSection(context, appCategory)
|
||||
//Search ---------------------------------------------
|
||||
val searchPreference = EditTextPreference(context)
|
||||
searchPreference.title = getString(R.string.search_engine)
|
||||
searchPreference.key = "search_base"
|
||||
searchPreference.dialogTitle = getString(R.string.search_base)
|
||||
|
||||
val searchengine = preferenceManager.sharedPreferences.getString(
|
||||
"search_base",
|
||||
Buran.DEFAULT_SEARCH_BASE
|
||||
)?.trim()
|
||||
|
||||
searchPreference.summary = if(searchengine.isNullOrEmpty()){
|
||||
Buran.DEFAULT_SEARCH_BASE
|
||||
}else if(
|
||||
!searchengine.startsWith("gemini://")
|
||||
or searchengine.contains(" ")
|
||||
or !searchengine.contains(".")
|
||||
){
|
||||
context.getString(R.string.not_valid_address)
|
||||
}else if(!searchengine.endsWith("?")){
|
||||
context.getString(R.string.not_valid_search_string)
|
||||
}else{
|
||||
searchengine
|
||||
}
|
||||
|
||||
searchPreference.positiveButtonText = getString(R.string.update)
|
||||
searchPreference.negativeButtonText = getString(R.string.cancel)
|
||||
searchPreference.setOnPreferenceChangeListener { _, newValue ->
|
||||
val newSearchBase = newValue.toString().trim()
|
||||
searchPreference.summary = if(newSearchBase.isNullOrEmpty()){
|
||||
Buran.DEFAULT_SEARCH_BASE
|
||||
}else if(
|
||||
!newSearchBase.startsWith("gemini://")
|
||||
or newSearchBase.contains(" ")
|
||||
or !newSearchBase.contains(".")
|
||||
){
|
||||
context.getString(R.string.not_valid_address)
|
||||
}else if(!newSearchBase.endsWith("?")){
|
||||
context.getString(R.string.not_valid_search_string)
|
||||
}else{
|
||||
newSearchBase
|
||||
}
|
||||
true
|
||||
}
|
||||
searchPreference.setOnBindEditTextListener{ editText ->
|
||||
editText.imeOptions = EditorInfo.IME_ACTION_DONE
|
||||
editText.setSelection(editText.text.toString().length)//Set caret position to end
|
||||
}
|
||||
appCategory.addPreference(searchPreference)
|
||||
|
||||
//Updates ---------------------------------------------
|
||||
val sideloadedHashCode = -899861527
|
||||
val isSideloaded = context.packageManager.getPackageInfo(
|
||||
context.packageName,
|
||||
PackageManager.GET_SIGNATURES
|
||||
).signatures[0].hashCode() == sideloadedHashCode
|
||||
|
||||
val checkForUpdates = SwitchPreferenceCompat(context)
|
||||
checkForUpdates.setDefaultValue(false)
|
||||
checkForUpdates.key = "check_for_updates"
|
||||
checkForUpdates.title = getString(R.string.check_for_updates)
|
||||
checkForUpdates.isVisible = isSideloaded
|
||||
appCategory.addPreference(checkForUpdates)
|
||||
|
||||
//Certificates
|
||||
buildClientCertificateSection(context, screen)
|
||||
|
||||
//Appearance --------------------------------------------
|
||||
buildAppearanceSection(context, appCategory)
|
||||
|
@ -104,6 +191,50 @@ class SettingsFragment: PreferenceFragmentCompat(), Preference.OnPreferenceChang
|
|||
showInlineImages.title = getString(R.string.show_inline_images)
|
||||
webCategory.addPreference(showInlineImages)
|
||||
|
||||
val httpGeminiProxy = EditTextPreference(context)
|
||||
httpGeminiProxy.title = getString(R.string.http_proxy)
|
||||
httpGeminiProxy.key = "http_proxy"
|
||||
httpGeminiProxy.dialogTitle = getString(R.string.http_proxy)
|
||||
|
||||
val httpProxy = preferenceManager.sharedPreferences.getString(
|
||||
"http_proxy",
|
||||
null
|
||||
)?.trim()
|
||||
|
||||
httpGeminiProxy.summary = if(httpProxy.isNullOrEmpty()){
|
||||
getString(R.string.no_http_proxy_set)
|
||||
}else if(
|
||||
!httpProxy.startsWith("gemini://")
|
||||
or httpProxy.contains(" ")
|
||||
or !httpProxy.contains(".")
|
||||
){
|
||||
getString(R.string.not_valid_address)
|
||||
}else{
|
||||
httpProxy
|
||||
}
|
||||
|
||||
httpGeminiProxy.positiveButtonText = getString(R.string.update)
|
||||
httpGeminiProxy.negativeButtonText = getString(R.string.cancel)
|
||||
httpGeminiProxy.setOnPreferenceChangeListener { _, newValue ->
|
||||
val newHomecapsule = newValue.toString().trim()
|
||||
httpGeminiProxy.summary = if(newHomecapsule.isNullOrEmpty()){
|
||||
getString(R.string.no_http_proxy_set)
|
||||
}else if(
|
||||
!newHomecapsule.startsWith("gemini://")
|
||||
or newHomecapsule.contains(" ")
|
||||
or !newHomecapsule.contains(".")
|
||||
){
|
||||
getString(R.string.not_valid_address)
|
||||
}else{
|
||||
newHomecapsule
|
||||
}
|
||||
true
|
||||
}
|
||||
httpGeminiProxy.setOnBindEditTextListener{ editText ->
|
||||
editText.imeOptions = EditorInfo.IME_ACTION_DONE
|
||||
editText.setSelection(editText.text.toString().length)//Set caret position to end
|
||||
}
|
||||
webCategory.addPreference(httpGeminiProxy)
|
||||
}
|
||||
|
||||
private fun buildAppearanceSection(context: Context?, appCategory: PreferenceCategory) {
|
||||
|
@ -192,82 +323,154 @@ class SettingsFragment: PreferenceFragmentCompat(), Preference.OnPreferenceChang
|
|||
showLinkButtonsPreference.key = "show_link_buttons"
|
||||
showLinkButtonsPreference.title = getString(R.string.show_link_buttons)
|
||||
accessibilityCategory.addPreference(showLinkButtonsPreference)
|
||||
|
||||
//Accessibility - gemtext attention guides
|
||||
val attentionGuidingText = SwitchPreferenceCompat(context)
|
||||
attentionGuidingText.setDefaultValue(false)
|
||||
attentionGuidingText.key = "use_attention_guides"
|
||||
attentionGuidingText.title = getString(R.string.use_attention_guides)
|
||||
accessibilityCategory.addPreference(attentionGuidingText)
|
||||
}
|
||||
|
||||
private fun buildClientCertificateSection(context: Context?, appCategory: PreferenceCategory) {
|
||||
if (Buran.FEATURE_CLIENT_CERTS) {
|
||||
private fun buildClientCertificateSection(context: Context?, screen: PreferenceScreen) {
|
||||
|
||||
val aboutPref = Preference(context)
|
||||
aboutPref.key = "unused_pref"
|
||||
aboutPref.summary = getString(R.string.pkcs_notice)
|
||||
aboutPref.isPersistent = false
|
||||
aboutPref.isSelectable = false
|
||||
appCategory.addPreference(aboutPref)
|
||||
val certificateCategory = PreferenceCategory(context)
|
||||
certificateCategory.key = "certificate_category"
|
||||
certificateCategory.title = getString(R.string.client_certificate)
|
||||
screen.addPreference(certificateCategory)
|
||||
|
||||
clientCertPref = Preference(context)
|
||||
clientCertPref.title = getString(R.string.client_certificate)
|
||||
clientCertPref.key = Buran.PREF_KEY_CLIENT_CERT_HUMAN_READABLE
|
||||
val aboutPref = Preference(context)
|
||||
aboutPref.summary = getString(R.string.pkcs_notice)
|
||||
aboutPref.isPersistent = false
|
||||
aboutPref.isSelectable = false
|
||||
certificateCategory.addPreference(aboutPref)
|
||||
|
||||
val clientCertUriHumanReadable = preferenceManager.sharedPreferences.getString(
|
||||
Buran.PREF_KEY_CLIENT_CERT_HUMAN_READABLE,
|
||||
null
|
||||
)
|
||||
clientCertPref = Preference(context)
|
||||
clientCertPref.title = getString(R.string.client_certificate)
|
||||
clientCertPref.key = Buran.PREF_KEY_CLIENT_CERT_HUMAN_READABLE
|
||||
|
||||
val hasCert = clientCertUriHumanReadable != null
|
||||
if (!hasCert) {
|
||||
clientCertPref.summary = getString(R.string.tap_to_select_client_certificate)
|
||||
} else {
|
||||
clientCertPref.summary = clientCertUriHumanReadable
|
||||
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 = "application/x-pkcs12"
|
||||
}
|
||||
startActivityForResult(intent, PREFS_SET_CLIENT_CERT_REQ)
|
||||
true
|
||||
}
|
||||
|
||||
clientCertPref.setOnPreferenceClickListener {
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
type = "*/*"
|
||||
certificateCategory.addPreference(clientCertPref)
|
||||
|
||||
val clientCertPassword = EditTextPreference(context)
|
||||
clientCertPassword.key = Buran.PREF_KEY_CLIENT_CERT_PASSWORD
|
||||
clientCertPassword.title = getString(R.string.client_certificate_password)
|
||||
|
||||
var certPassword = preferenceManager.sharedPreferences.getString(
|
||||
Buran.PREF_KEY_CLIENT_CERT_PASSWORD,
|
||||
null
|
||||
)
|
||||
|
||||
clientCertPassword.dialogTitle = getString(R.string.client_certificate_password)
|
||||
if (certPassword != null && certPassword.isNotEmpty()) {
|
||||
clientCertPassword.summary = getDots(certPassword)
|
||||
} else {
|
||||
clientCertPassword.summary = getString(R.string.no_password)
|
||||
}
|
||||
clientCertPassword.isVisible = !preferenceManager.sharedPreferences.getBoolean("use_biometrics", false)
|
||||
certificateCategory.addPreference(clientCertPassword)
|
||||
|
||||
val useBiometrics = SwitchPreferenceCompat(context)
|
||||
useBiometrics.setDefaultValue(false)
|
||||
useBiometrics.key = "use_biometrics"
|
||||
useBiometrics.title = getString(R.string.biometric_cert_verification)
|
||||
useBiometrics.isVisible = false
|
||||
certificateCategory.addPreference(useBiometrics)
|
||||
|
||||
val passwordCiphertext = EditTextPreference(context)
|
||||
passwordCiphertext.key = "password_ciphertext"
|
||||
passwordCiphertext.isVisible = false
|
||||
certificateCategory.addPreference(passwordCiphertext)
|
||||
|
||||
val passwordInitVector = EditTextPreference(context)
|
||||
passwordInitVector.key = "password_init_vector"
|
||||
passwordInitVector.isVisible = false
|
||||
certificateCategory.addPreference(passwordInitVector)
|
||||
|
||||
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.P){
|
||||
useBiometrics.isVisible = (certPassword?.isNotEmpty() ?: false) or useBiometrics.isChecked
|
||||
|
||||
useBiometrics.setOnPreferenceChangeListener { _, newValue ->
|
||||
val biometricManager = BuranBiometricManager()
|
||||
|
||||
val callback = object : BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||
super.onAuthenticationError(errorCode, errString)
|
||||
println("Authentication error: $errorCode: $errString")
|
||||
useBiometrics.isChecked = !(newValue as Boolean)
|
||||
}
|
||||
override fun onAuthenticationFailed() {
|
||||
super.onAuthenticationFailed()
|
||||
println("Authentication failed")
|
||||
useBiometrics.isChecked = !(newValue as Boolean)
|
||||
}
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
super.onAuthenticationSucceeded(result)
|
||||
println("Authentication succeeded")
|
||||
|
||||
if(newValue as Boolean){
|
||||
println(certPassword)
|
||||
val encryptedData = biometricManager.encryptData(certPassword!!, result.cryptoObject?.cipher!!)
|
||||
val ciphertext = encryptedData.ciphertext
|
||||
val initializationVector = encryptedData.initializationVector
|
||||
passwordInitVector.text = initializationVector.contentToString()
|
||||
passwordCiphertext.text = ciphertext.contentToString()
|
||||
clientCertPassword.text = null
|
||||
}else{
|
||||
val ciphertext = biometricManager.decodeByteArray(passwordCiphertext.text)
|
||||
clientCertPassword.text = biometricManager.decryptData(ciphertext, result.cryptoObject?.cipher!!)
|
||||
clientCertPassword.summary = getDots(clientCertPassword.text)
|
||||
}
|
||||
clientCertPassword.isVisible = !(newValue as Boolean)
|
||||
clientCertPref.isEnabled = !(newValue as Boolean)
|
||||
}
|
||||
}
|
||||
startActivityForResult(intent, PREFS_SET_CLIENT_CERT_REQ)
|
||||
|
||||
biometricManager.createBiometricPrompt(requireContext(), this, null, callback)
|
||||
|
||||
if(newValue as Boolean){
|
||||
biometricManager.authenticateToEncryptData()
|
||||
}else{
|
||||
val initializationVector = biometricManager.decodeByteArray(passwordInitVector.text)
|
||||
biometricManager.authenticateToDecryptData(initializationVector)
|
||||
}
|
||||
|
||||
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.setOnPreferenceChangeListener { _, newValue ->
|
||||
val passphrase = "$newValue"
|
||||
if (passphrase.isEmpty()) {
|
||||
clientCertPassword.summary = getString(R.string.no_password)
|
||||
useBiometrics.isVisible = false
|
||||
} else {
|
||||
clientCertPassword.summary = getDots(passphrase)
|
||||
useBiometrics.isVisible = true
|
||||
}
|
||||
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
|
||||
}
|
||||
certPassword = passphrase
|
||||
true//update the value
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -293,7 +496,6 @@ class SettingsFragment: PreferenceFragmentCompat(), Preference.OnPreferenceChang
|
|||
persistPermissions(uri)
|
||||
findFilename(uri)
|
||||
}
|
||||
|
||||
}
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
}
|
||||
|
@ -321,6 +523,5 @@ class SettingsFragment: PreferenceFragmentCompat(), Preference.OnPreferenceChang
|
|||
readableReference
|
||||
).apply()
|
||||
clientCertPref.summary = readableReference
|
||||
useClientCertPreference.isChecked = true
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:width="16dp"
|
||||
android:height="16dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
|
|
|
@ -45,6 +45,7 @@
|
|||
android:layout_width="@dimen/button_size"
|
||||
android:layout_height="@dimen/button_size"
|
||||
android:layout_centerVertical="true"
|
||||
android:layout_alignParentStart="true"
|
||||
android:layout_margin="@dimen/button_margin"
|
||||
android:background="?android:attr/selectableItemBackgroundBorderless"
|
||||
android:src="@drawable/vector_home"/>
|
||||
|
@ -80,7 +81,7 @@
|
|||
android:id="@+id/more"
|
||||
android:layout_width="@dimen/button_size"
|
||||
android:layout_height="@dimen/button_size"
|
||||
android:layout_alignParentRight="true"
|
||||
android:layout_alignParentEnd="true"
|
||||
android:layout_centerVertical="true"
|
||||
android:layout_margin="@dimen/button_margin"
|
||||
android:background="?android:attr/selectableItemBackgroundBorderless"
|
||||
|
|
|
@ -1,103 +0,0 @@
|
|||
<?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"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<androidx.appcompat.widget.Toolbar
|
||||
android:id="@+id/about_toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_marginTop="@dimen/default_margin"
|
||||
android:layout_height="@dimen/bar_height"
|
||||
app:title="@string/about"/>
|
||||
|
||||
<androidx.core.widget.NestedScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_below="@+id/about_toolbar">
|
||||
<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>
|
|
@ -2,61 +2,14 @@
|
|||
<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"/>
|
||||
android:paddingHorizontal="@dimen/screen_margin">
|
||||
|
||||
<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>
|
||||
android:inputType="text"
|
||||
android:lines="1"
|
||||
android:maxLines="1"/>
|
||||
|
||||
</RelativeLayout>
|
|
@ -21,7 +21,7 @@
|
|||
android:textSize="@dimen/code_text_size"
|
||||
android:typeface="monospace"
|
||||
android:textColor="@color/stroke"
|
||||
android:id="@id/gemtext_text_monospace_textview"
|
||||
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"
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<?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"
|
||||
<androidx.appcompat.widget.AppCompatTextView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:id="@+id/gemtext_quote_textview"
|
||||
android:textSize="@dimen/default_text_size"
|
||||
android:textColor="@color/stroke"
|
||||
android:layout_marginLeft="@dimen/screen_margin"
|
||||
|
@ -15,4 +15,4 @@
|
|||
android:textIsSelectable="true"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:lineHeight="@dimen/default_line_height"/>
|
||||
app:lineHeight="@dimen/default_line_height" />
|
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.appcompat.widget.AppCompatTextView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
<androidx.appcompat.widget.AppCompatTextView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:id="@+id/gemtext_text_textview"
|
||||
android:textSize="@dimen/default_text_size"
|
||||
android:layout_marginLeft="@dimen/screen_margin"
|
||||
|
@ -9,4 +9,4 @@
|
|||
android:textIsSelectable="true"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:lineHeight="@dimen/default_line_height"/>
|
||||
app:lineHeight="@dimen/default_line_height" />
|
|
@ -4,6 +4,9 @@
|
|||
<item
|
||||
android:id="@+id/overflow_menu_search"
|
||||
android:title="@string/search"/>
|
||||
<item
|
||||
android:id="@+id/overflow_menu_sign"
|
||||
android:title="@string/load_cert"/>
|
||||
<item
|
||||
android:id="@+id/overflow_menu_bookmark"
|
||||
android:title="@string/add_bookmark"/>
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools"
|
||||
tools:shrinkMode="strict" />
|
|
@ -1,21 +1,21 @@
|
|||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<string name="app_name">Buran</string>
|
||||
<string name="app_name">Bourane</string>
|
||||
<string name="gemini_protocol">gemini://</string>
|
||||
<string name="main_input_hint">Entrez l\'adresse gemini://</string>
|
||||
<string name="main_input_search_hint">Entrez un terme de recherche</string>
|
||||
<string name="copy_address">Partager l\'adresse</string>
|
||||
<string name="load_image">Afficher en ligne</string>
|
||||
<string name="about">À propos</string>
|
||||
<string name="address_copied_to_clipboard">Adresse copiée dans le presse-papiers</string>
|
||||
<string name="gemini_address">Adresse Gemini</string>
|
||||
<string name="share">Partager</string>
|
||||
<string name="set_home">Choisir comme Accueil</string>
|
||||
<string name="settings">Paramètres</string>
|
||||
<string name="about_body">Buran: Un client pour le protocole Gemini par Corewala</string>
|
||||
<string name="about_body">Un navigateur minimaliste pour le protocole Gemini par Corewala</string>
|
||||
<string name="copyright">Copyright © 2022 Corewala</string>
|
||||
<string name="about_ariane_source">Buran est basé sur le navigateur Ariane d\'ÖLAB sous la Licence Publique de l\'Union Européenne</string>
|
||||
<string name="about_ariane_source">Bourane est basé sur le navigateur Ariane d\'ÖLAB sous la Licence Publique de l\'Union Européenne</string>
|
||||
<string name="about_font">Les blocs de code sont rendus avec JetBrains Mono de JetBrains</string>
|
||||
<string name="about_glyphs">Les glyphes utilisés proviennent de Material Icons par Google</string>
|
||||
<string name="source">Source</string>
|
||||
<string name="clear_cache">Vider le cache d\'exécution</string>
|
||||
<string name="history">Historique</string>
|
||||
<string name="clear_history">Vider l\'historique</string>
|
||||
|
@ -37,15 +37,23 @@
|
|||
<string name="move_down">Déplacer vers le bas</string>
|
||||
<string name="move_up">Déplacer vers le haut</string>
|
||||
<string name="unknown_mime_dialog_title">Type Mime inconnu</string>
|
||||
<string name="unknown_host_dialog_title">Hôte inconnu</string>
|
||||
<string name="unknown_host">Hôte inconnu</string>
|
||||
<string name="search_instead">Rechercher au lieu?</string>
|
||||
<string name="download">Télécharger</string>
|
||||
<string name="cancel">Annuler</string>
|
||||
<string name="close">Fermer</string>
|
||||
<string name="confirm">Confirmer</string>
|
||||
<string name="error">Erreur</string>
|
||||
<string name="no_app_installed_that_can_open">Aucune app installée qui puisse ouvrir %s</string>
|
||||
<string name="no_state_object_exists">Erreur de téléchargement de fichier - aucun état d\'objet n\'existe</string>
|
||||
<string name="file_saved_to_device">Fichier sauvegardé dans l\'appareil</string>
|
||||
<string name="configure_buran">Configurer Buran</string>
|
||||
<string name="home_capsule">Capsule d\'accueil</string>
|
||||
<string name="not_valid_search_string">Ceci n\'est pas une base de recherche valide</string>
|
||||
<string name="not_valid_address">Ceci n\'est pas une adresse valide</string>
|
||||
<string name="no_home_capsule_set">Pas de capsule d\'accueil</string>
|
||||
<string name="search_engine">Moteur de recherche</string>
|
||||
<string name="search_base">Base de recherche</string>
|
||||
<string name="update">Mettre à jour</string>
|
||||
<string name="appearance">Apparence</string>
|
||||
<string name="theme">Thème</string>
|
||||
|
@ -57,18 +65,35 @@
|
|||
<string name="web_content">Contenu Web</string>
|
||||
<string name="web_content_label">Ouvrir les sites web en interne en utilisant des \'Onglets Personnalisés\', plutôt que d\'utiliser le navigateur par défaut. Cela pourrait vous aider à rester dans le Geminispace plutôt que d\'être distrait·e par le vaste web. Cela requiert un navigateur par défaut compatible.</string>
|
||||
<string name="web_content_switch_label">Ouvrir en interne</string>
|
||||
<string name="http_proxy">Mandataire HTTP</string>
|
||||
<string name="no_http_proxy_set">Pas de mandataire HTTP</string>
|
||||
<string name="proxied_content">Ce contenu est visualisé via un mandataire</string>
|
||||
<string name="open_original">Ouvrir l\'original</string>
|
||||
<string name="show_inline_images">Images locales en ligne</string>
|
||||
<string name="pkcs_notice">Seuls les magasins de clés client PKCS12 sont actuellement supportés</string>
|
||||
<string name="pkcs_notice">Seuls les magasins de clés client PKCS12 sont actuellement supportés.</string>
|
||||
<string name="client_certificate">Certificat Client</string>
|
||||
<string name="tap_to_select_client_certificate">Cliquez pour sélectionner un certificat client</string>
|
||||
<string name="client_certificate_password">Mot de passe du Certificat Client</string>
|
||||
<string name="no_password">Pas de mot de passe</string>
|
||||
<string name="use_client_certificate">Utiliser un Certificat Client</string>
|
||||
<string name="use_client_certificate">Utiliser Certificat Client</string>
|
||||
<string name="client_certificate_required">Certificat Client Requis</string>
|
||||
<string name="confirm_your_identity">Confirmez votre identité</string>
|
||||
<string name="use_biometric_unlock">Utilisez vos informations biométriques pour continuer</string>
|
||||
<string name="biometric_cert_verification">Certificat Client biométrique</string>
|
||||
<string name="load_cert">Chargez Certificat</string>
|
||||
<string name="unload_cert">Déchargez Certificat</string>
|
||||
<string name="cert_loaded">Certificat chargé</string>
|
||||
<string name="cert_unloaded">Certificat dechargé</string>
|
||||
<string name="set_home_capsule">Choisir comme capsule d\'accueil</string>
|
||||
<string name="check_for_updates">Vérifier pour des mises à jour</string>
|
||||
<string name="new_version_available">Nouvelle version disponible</string>
|
||||
<string name="no_internet">Aucun accès internet</string>
|
||||
<string name="retry">Rafraichissez cette page pour réessayer</string>
|
||||
<string name="history_cleared">Historique vidé</string>
|
||||
<string name="runtime_cache_cleared">Cache d\'exécution vidé</string>
|
||||
<string name="show_inline_icons">Icônes de lien en ligne</string>
|
||||
<string name="show_link_buttons">Boutons de lien</string>
|
||||
<string name="use_attention_guides">Utiliser des guides d\'attention</string>
|
||||
<string name="bookmarks_empty">Vous n\'avez encore aucun marque-pages</string>
|
||||
<string name="import_bookmarks">Importer des marque-pages</string>
|
||||
<string name="export_bookmarks">Exporter des marque-pages</string>
|
||||
|
|
|
@ -4,18 +4,18 @@
|
|||
<string name="main_input_hint">Enter gemini:// address</string>
|
||||
<string name="main_input_search_hint">Enter search term</string>
|
||||
<string name="copy_address">Share address</string>
|
||||
<string name="load_image">Display inline</string>
|
||||
<string name="about">About</string>
|
||||
<string name="address_copied_to_clipboard">Address copied to clipboard</string>
|
||||
<string name="gemini_address">Gemini address</string>
|
||||
<string name="share">Share</string>
|
||||
<string name="set_home">Set Home</string>
|
||||
<string name="settings">Settings</string>
|
||||
<string name="about_body">Buran: A Gemini protocol browser from Corewala</string>
|
||||
<string name="about_body">A simple Gemini protocol browser from Corewala</string>
|
||||
<string name="copyright">Copyright © 2022 Corewala</string>
|
||||
<string name="about_ariane_source">Buran is based on the Ariane browser by ÖLAB under the European Union Public Licence</string>
|
||||
<string name="about_font">Code blocks are rendered using JetBrains Mono by JetBrains</string>
|
||||
<string name="about_glyphs">Glyphs used are from Material Icons by Google</string>
|
||||
<string name="source">Source</string>
|
||||
<string name="clear_cache">Clear runtime cache</string>
|
||||
<string name="history">History</string>
|
||||
<string name="clear_history">Clear history</string>
|
||||
|
@ -37,15 +37,23 @@
|
|||
<string name="move_down">Move down</string>
|
||||
<string name="move_up">Move up</string>
|
||||
<string name="unknown_mime_dialog_title">Unknown Mime Type</string>
|
||||
<string name="unknown_host_dialog_title" tools:ignore="MissingTranslation">Unknown Host</string>
|
||||
<string name="unknown_host">Unknown Host</string>
|
||||
<string name="search_instead">Search instead?</string>
|
||||
<string name="download">Download</string>
|
||||
<string name="cancel">Cancel</string>
|
||||
<string name="close">Close</string>
|
||||
<string name="confirm">Ok</string>
|
||||
<string name="error">Error</string>
|
||||
<string name="no_app_installed_that_can_open">No app installed that can open %s</string>
|
||||
<string name="no_state_object_exists">File download error - no state object exists</string>
|
||||
<string name="file_saved_to_device">File saved to device</string>
|
||||
<string name="configure_buran">Configure Buran</string>
|
||||
<string name="home_capsule">Home Capsule</string>
|
||||
<string name="not_valid_search_string">This is not a valid search base</string>
|
||||
<string name="not_valid_address">This is not a valid address</string>
|
||||
<string name="no_home_capsule_set">No home capsule set</string>
|
||||
<string name="search_engine">Search Engine</string>
|
||||
<string name="search_base">Search Base</string>
|
||||
<string name="update">Update</string>
|
||||
<string name="appearance">Appearance</string>
|
||||
<string name="theme">Theme</string>
|
||||
|
@ -57,18 +65,35 @@
|
|||
<string name="web_content">Web Content</string>
|
||||
<string name="web_content_label">Open websites internally using \'Custom Tabs\', instead of using the default browser. This might help you stay in Geminispace instead of being distracted by the wider web. Requires compatible default browser.</string>
|
||||
<string name="web_content_switch_label">Open internally</string>
|
||||
<string name="http_proxy">HTTP proxy</string>
|
||||
<string name="no_http_proxy_set">No HTTP proxy set</string>
|
||||
<string name="proxied_content">This content is rendered through a proxy</string>
|
||||
<string name="open_original">Open original</string>
|
||||
<string name="show_inline_images">Inline local images</string>
|
||||
<string name="pkcs_notice">Only PKCS12 client keystores are currently supported</string>
|
||||
<string name="pkcs_notice">Only PKCS12 client keystores are currently supported.</string>
|
||||
<string name="client_certificate">Client Certificate</string>
|
||||
<string name="tap_to_select_client_certificate">Tap to select client certificate</string>
|
||||
<string name="client_certificate_password">Client Certificate Password</string>
|
||||
<string name="no_password">No Password</string>
|
||||
<string name="use_client_certificate">Use Client Certificate</string>
|
||||
<string name="client_certificate_required">Client Certificate Required</string>
|
||||
<string name="confirm_your_identity">Confirm your identity</string>
|
||||
<string name="use_biometric_unlock">Verify your biometric credentials to continue</string>
|
||||
<string name="biometric_cert_verification">Client Certificate biometrics</string>
|
||||
<string name="load_cert">Load Certificate</string>
|
||||
<string name="unload_cert">Unload Certificate</string>
|
||||
<string name="cert_loaded">Certificate loaded</string>
|
||||
<string name="cert_unloaded">Certificate unloaded</string>
|
||||
<string name="set_home_capsule">Set home capsule</string>
|
||||
<string name="check_for_updates">Check for updates</string>
|
||||
<string name="new_version_available">New version available</string>
|
||||
<string name="no_internet">No internet access</string>
|
||||
<string name="retry">Refresh this page to try again</string>
|
||||
<string name="history_cleared">History cleared</string>
|
||||
<string name="runtime_cache_cleared">Runtime cache cleared</string>
|
||||
<string name="show_inline_icons">Inline link icons</string>
|
||||
<string name="show_link_buttons">Show link buttons</string>
|
||||
<string name="use_attention_guides">Use attention guides</string>
|
||||
<string name="bookmarks_empty">You don\'t have any bookmarks yet</string>
|
||||
<string name="import_bookmarks">Import bookmarks</string>
|
||||
<string name="export_bookmarks">Export bookmarks</string>
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
<item name="android:windowLightStatusBar" tools:targetApi="m">true</item>
|
||||
<item name="android:windowLightNavigationBar" tools:targetApi="o_mr1">true</item>
|
||||
<item name="android:navigationBarColor">@color/navigation_bar_background</item>
|
||||
<item name="android:windowDisablePreview">true</item>
|
||||
</style>
|
||||
|
||||
<style name="SettingsTheme" parent="@style/AppTheme">
|
||||
|
|
|
@ -22,3 +22,5 @@ kotlin.code.style=official
|
|||
# Kapt workaround
|
||||
kapt.use.worker.api=false
|
||||
kapt.incremental.apt=false
|
||||
# R8 optimisation
|
||||
android.enableR8.fullMode=true
|
||||
|
|
|
@ -1,11 +0,0 @@
|
|||
## This file must *NOT* be checked into Version Control Systems,
|
||||
# as it contains information specific to your local configuration.
|
||||
#
|
||||
# Location of the SDK. This is only used by Gradle.
|
||||
# For customization when using a Version Control System, please read the
|
||||
# header note.
|
||||
#Mon Nov 29 22:38:09 EST 2021
|
||||
sdk.dir=/home/corewala/.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,9 @@
|
|||
Changelog:
|
||||
|
||||
- Default homepage is stored locally
|
||||
|
||||
- Only the latest request is rendered
|
||||
|
||||
- Back button cancels current request
|
||||
|
||||
- Lots of bugfixes
|
|
@ -0,0 +1,9 @@
|
|||
Changelog:
|
||||
|
||||
- HTTP proxy support
|
||||
|
||||
- Previous scroll position is stored
|
||||
|
||||
- Local links and redirects are fixed
|
||||
|
||||
- Minor tweaks and bugfixes
|
|
@ -0,0 +1,9 @@
|
|||
Changelog:
|
||||
|
||||
- Fixed broken unproxied HTTP links
|
||||
|
||||
- Un-bugged the back button
|
||||
|
||||
- This is a really tiny release
|
||||
|
||||
- Actual features coming at some point in the nebulous future
|
|
@ -0,0 +1,5 @@
|
|||
Buran is a simple Gemini protocol browser for Android which allows users to explore geminispace in style.
|
||||
|
||||
This application has no external dependencies and does not require any nonfree services, using only default system libraries. It has been fully localized in English and French, and supports an array of accessibility and quality-of-life features.
|
||||
|
||||
Buran is a fork of Ariane by ÖLAB allowed under the terms of the European Union Public Licence.
|
After Width: | Height: | Size: 24 KiB |
After Width: | Height: | Size: 107 KiB |
After Width: | Height: | Size: 112 KiB |
After Width: | Height: | Size: 692 KiB |
After Width: | Height: | Size: 1.4 MiB |
|
@ -0,0 +1 @@
|
|||
Simple Gemini browser for Android
|
|
@ -0,0 +1 @@
|
|||
Buran
|
|
@ -0,0 +1,5 @@
|
|||
Bourane est un navigateur minimaliste du protocole Gemini sur Android pour explorer l'espace Gemini avec style.
|
||||
|
||||
Cette application n'a aucune dépendance externe et ne nécessite aucun service non gratuit, en utilisant uniquement les bibliothèques système.
|
||||
|
||||
Bourane est basé sur le navigateur Ariane d'ÖLAB autorisé conformément aux termes de la Licence Publique de l'Union Européenne.
|
After Width: | Height: | Size: 159 KiB |
After Width: | Height: | Size: 165 KiB |
After Width: | Height: | Size: 1.1 MiB |
|
@ -0,0 +1 @@
|
|||
Navigateur minimaliste pour le protocole Gemini
|
|
@ -0,0 +1 @@
|
|||
Bourane
|