Compare commits

..

1 commit

Author SHA1 Message Date
dfd771adfe WIP: Implement undo/redo commands
Signed-off-by: William Brawner <me@wbrawner.com>
2020-07-09 21:48:28 -07:00
175 changed files with 2735 additions and 4569 deletions

View file

@ -1,70 +0,0 @@
name: Build & Test
on:
pull_request:
jobs:
validate:
runs-on: ubuntu-latest
name: Validate
steps:
- uses: actions/checkout@v4
- name: set up JDK
uses: https://git.wbrawner.com/actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '17'
- name: Validate Gradle Wrapper
uses: https://git.wbrawner.com/gradle/actions/wrapper-validation@v4
unit_tests:
name: Run Unit Tests
runs-on: ubuntu-latest
needs:
- validate
steps:
- uses: actions/checkout@v4
- name: set up JDK
uses: https://git.wbrawner.com/actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '17'
- name: Setup Android SDK
uses: https://git.wbrawner.com/android-actions/setup-android@v3
- name: Setup Gradle
uses: https://git.wbrawner.com/gradle/actions/setup-gradle@v4
- name: Run unit tests
run: ./gradlew check
- name: Publish JUnit Results
uses: actions/upload-artifact@v3
if: always()
with:
name: Unit Test Results
path: "*/build/reports/*"
if-no-files-found: error
ui_tests:
runs-on: ubuntu-latest
name: Run UI Tests
needs:
- unit_tests
steps:
- uses: actions/checkout@v4
- name: set up JDK
uses: https://git.wbrawner.com/actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '17'
- name: Setup Android SDK
uses: https://git.wbrawner.com/android-actions/setup-android@v3
- name: Setup Gradle
uses: https://git.wbrawner.com/gradle/actions/setup-gradle@v4
- name: Build APKs
run: ./gradlew assemblePlayDebug assemblePlayDebugAndroidTest
- name: Grant execute permission for flank_auth.sh
run: chmod +x flank_auth.sh
- name: Add auth for flank
env:
GCLOUD_KEY: ${{ secrets.GCLOUD_KEY }}
run: |
./flank_auth.sh
- name: Run UI tests
run: ./gradlew runFlank

View file

@ -1,77 +0,0 @@
name: Android CI
on:
pull_request:
push:
branches: [ main ]
jobs:
validate:
runs-on: ubuntu-latest
name: Validate
steps:
- uses: actions/checkout@v4
- name: set up JDK
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '17'
- name: Validate Gradle Wrapper
uses: gradle/wrapper-validation-action@v3
- name: Enable auto-merge
if: ${{ github.event_name == 'pull_request' && github.actor == 'dependabot' }}
run: gh pr merge --auto --rebase "$PR_URL"
env:
PR_URL: ${{github.event.pull_request.html_url}}
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
unit_test:
name: Run Unit Tests
runs-on: ubuntu-latest
needs:
- validate
steps:
- uses: actions/checkout@v4
- name: set up JDK
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '17'
- name: Run unit tests
uses: gradle/gradle-build-action@v3
with:
arguments: testPlayDebugUnitTest
- name: Publish JUnit Results
uses: dorny/test-reporter@v1
if: always()
with:
name: Unit Test Results
path: "*/build/test-results/*/*.xml"
reporter: java-junit
fail-on-error: true
ui_tests:
runs-on: ubuntu-latest
name: Run UI Tests
needs:
- validate
steps:
- uses: actions/checkout@v4
- name: set up JDK
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '17'
- name: Build with Gradle
uses: gradle/gradle-build-action@v3
with:
arguments: assemblePlayDebug assemblePlayDebugAndroidTest
- name: Grant execute permission for flank_auth.sh
run: chmod +x flank_auth.sh
- name: Add auth for flank
env:
GCLOUD_KEY: ${{ secrets.GCLOUD_KEY }}
run: |
./flank_auth.sh
- name: Run UI tests
uses: gradle/gradle-build-action@v3
with:
arguments: runFlank

1
.gitignore vendored
View file

@ -12,3 +12,4 @@
*.log
keystore.properties
*.jks
sentry.properties

View file

@ -1,4 +1,4 @@
image: openjdk:24-jdk
image: openjdk:8-jdk
variables:
ANDROID_COMPILE_SDK: "28"

View file

@ -1,36 +1,44 @@
# Simple Markdown
# [Simple Markdown](https://wbrawner.com/portfolio/simple-markdown/)
[![pipeline status](https://github.com/wbrawner/SimpleMarkdown/actions/workflows/android.yml/badge.svg)](https://github.com/wbrawner/SimpleMarkdown/actions/workflows/android.yml)
[![pipeline status](https://gitlab.com/billybrawner/SimpleMarkdown/badges/master/pipeline.svg)](https://gitlab.com/billybrawner/SimpleMarkdown/commits/master)
[![coverage report](https://gitlab.com/billybrawner/SimpleMarkdown/badges/master/coverage.svg)](https://gitlab.com/billybrawner/SimpleMarkdown/commits/master)
<p>
<img alt="" src="./fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" style="width: 24%" />
<img alt="" src="./fastlane/metadata/android/en-US/images/phoneScreenshots/2.png" style="width: 24%" />
<img alt="" src="./fastlane/metadata/android/en-US/images/phoneScreenshots/3.png" style="width: 24%" />
<img alt="" src="./fastlane/metadata/android/en-US/images/phoneScreenshots/4.png" style="width: 24%" />
</p>
Simple Markdown is simply a Markdown editor :) I wrote it to offer up an open source alternative to
the other Markdown editors available on the Play Store. I also wanted to get some practice in
creating Android apps and have a little something to put into my portfolio.
Simple Markdown is an open source Markdown editor.
## Roadmap
<a href='https://play.google.com/store/apps/details?id=com.wbrawner.simplemarkdown&pcampaignid=pcampaignidMKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1'><img alt='Get it on Google Play' src='https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png' height="80"/></a>
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png"
alt="Get it on F-Droid"
height="80">](https://f-droid.org/packages/com.wbrawner.simplemarkdown.free/)
* [x] Auto-save
* [x] Night mode
* [x] Save to cloud (Dropbox, Google Drive, OneDrive)
* [x] Custom CSS for Markdown preview
* [ ] Better insert for tables/images/links
* [ ] Quick-insert toolbar for common Markdown syntax characters
* [ ] Auto-scroll preview to match edit view in landscape mode
* [ ] Disable live preview in landscape mode
* [ ] Disable preview tab for better performance in large files
## Building
Using Android Studio is the preferred way to build the project. To build from the command line, you can run
./gradlew assembleFreeDebug
./gradlew assembleDebug
### Crashlytics
SimpleMarkdown makes use of Firebase Crashlytics for error reporting. You'll need to follow the
[Get started with Firebase Crashlytics](https://firebase.google.com/docs/crashlytics/get-started?platform=android) guide in order to build the project.
## Contributing
I'd love any contributions, particularly in improving the existing code. Please just fork the
repository, make your changes, squash your commits, and submit a pull request :)
## License
```
Copyright 2017-2022 William Brawner
Copyright 2017-2019 William Brawner
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.

1
app/.gitignore vendored
View file

@ -1,4 +1,3 @@
/build
*.apk
*.aab
/release

166
app/build.gradle Normal file
View file

@ -0,0 +1,166 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply plugin: 'jacoco'
apply plugin: 'io.sentry.android.gradle'
def keystoreProperties = new Properties()
try {
def keystorePropertiesFile = rootProject.file("keystore.properties")
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
} catch (FileNotFoundException ignored) {
logger.warn("Unable to load keystore properties. Automatic signing won't be available")
keystoreProperties['keyAlias'] = ""
keystoreProperties['keyPassword'] = ""
keystoreProperties['storeFile'] = File.createTempFile("temp", ".tmp").absolutePath
keystoreProperties['storePassword'] = ""
}
android {
configurations.all {
resolutionStrategy.force 'com.google.code.findbugs:jsr305:3.0.1'
}
packagingOptions {
exclude 'META-INF/LICENSE-LGPL-2.1.txt'
exclude 'META-INF/LICENSE-LGPL-3.txt'
exclude 'META-INF/LICENSE-W3C-TEST'
exclude 'META-INF/LICENSE'
exclude 'META-INF/DEPENDENCIES'
}
compileSdkVersion 29
buildToolsVersion '28.0.3'
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
defaultConfig {
applicationId "com.wbrawner.simplemarkdown"
minSdkVersion 21
targetSdkVersion 29
versionCode 27
versionName "0.8.5"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
manifestPlaceholders = [
sentryDsn: "https://399270639b2e4b10a028a2be9192d1d3@sentry.brawner.dev/2"
]
buildConfigField "boolean", "ENABLE_CUSTOM_CSS", "false"
}
signingConfigs {
release {
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
storeFile file(keystoreProperties['storeFile'])
storePassword keystoreProperties['storePassword']
}
}
buildTypes {
debug {
applicationIdSuffix ".debug"
testCoverageEnabled true
}
release {
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
signingConfig signingConfigs.release
buildConfigField "boolean", "ENABLE_CUSTOM_CSS", "false"
}
}
flavorDimensions "freedom"
productFlavors {
play {}
free {
applicationIdSuffix ".free"
versionNameSuffix "-free"
}
}
dexOptions {
jumboMode true
}
testOptions {
unitTests {
includeAndroidResources = true
}
execution 'ANDROIDX_TEST_ORCHESTRATOR'
}
}
dependencies {
implementation 'androidx.navigation:navigation-fragment-ktx:2.2.2'
implementation 'androidx.navigation:navigation-ui-ktx:2.2.2'
testImplementation 'junit:junit:4.12'
testImplementation 'org.robolectric:robolectric:4.2.1'
implementation fileTree(include: ['*.jar'], dir: 'libs')
def espresso_version = '3.2.0'
androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_version"
androidTestImplementation "androidx.test.espresso:espresso-web:$espresso_version"
androidTestImplementation "androidx.test.espresso:espresso-intents:$espresso_version"
def android_test = '1.2.0'
androidTestImplementation "androidx.test:runner:$android_test"
androidTestImplementation "androidx.test:rules:$android_test"
androidTestUtil "androidx.test:orchestrator:$android_test"
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.2.0'
implementation 'androidx.appcompat:appcompat:1.1.0'
implementation 'androidx.preference:preference:1.1.1'
implementation "androidx.fragment:fragment-ktx:1.2.4"
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
implementation 'com.google.android.material:material:1.1.0'
implementation 'androidx.legacy:legacy-support-v13:1.0.0'
implementation 'com.commonsware.cwac:anddown:0.3.0'
playImplementation 'com.android.billingclient:billing:3.0.0'
playImplementation 'com.google.firebase:firebase-core:17.4.3'
implementation "androidx.core:core-ktx:1.3.0"
implementation 'androidx.browser:browser:1.2.0'
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'io.sentry:sentry-android:2.1.6'
def coroutines_version = "1.3.4"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version"
def lifecycle_version = "2.2.0"
implementation "androidx.lifecycle:lifecycle-extensions:$lifecycle_version"
kapt "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version"
implementation 'eu.crydee:syllable-counter:4.0.2'
}
android.productFlavors.each { flavor ->
if (getGradle().getStartParameter().getTaskRequests().toString().toLowerCase().contains(flavor.name)
&& flavor.name == 'play') {
apply plugin: 'com.google.gms.google-services'
}
}
repositories {
mavenCentral()
jcenter()
}
jacoco {
toolVersion = '0.8.0'
}
tasks.withType(Test) {
jacoco.includeNoLocationClasses = true
}
task jacocoTestReport(type: JacocoReport, dependsOn: ['testDebugUnitTest']) {
reports {
xml.enabled = true
html.enabled = true
}
def fileFilter = [ '**/R.class', '**/R$*.class', '**/BuildConfig.*', '**/Manifest*.*', '**/*Test*.*', 'android/**/*.*' ]
def javaDebugTree = fileTree(dir: "$project.buildDir/intermediates/javac/debug/compileDebugJavaWithJavac/classes", excludes: fileFilter)
def kotlinDebugTree = fileTree(dir: "$project.buildDir/tmp/kotlin-classes/debug", excludes: fileFilter)
def mainSrc = "$project.projectDir/src/main/java"
sourceDirectories.setFrom(files([mainSrc]))
classDirectories.setFrom(files([javaDebugTree, kotlinDebugTree]))
executionData.setFrom(fileTree(dir: project.buildDir, includes: [
'jacoco/testDebugUnitTest.exec',
'outputs/code-coverage/connected/*coverage.ec'
]))
}

View file

@ -1,201 +0,0 @@
import java.io.FileInputStream
import java.io.FileNotFoundException
import java.util.Properties
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.compose.compiler)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.fladle)
alias(libs.plugins.triplet.play)
id("com.wbrawner.releasehelper")
}
val keystoreProperties = Properties()
try {
val keystorePropertiesFile = rootProject.file("keystore.properties")
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
} catch (ignored: FileNotFoundException) {
logger.warn("Unable to load keystore properties. Automatic signing won't be available")
keystoreProperties["keyAlias"] = ""
keystoreProperties["keyPassword"] = ""
keystoreProperties["storeFile"] = File.createTempFile("temp", ".tmp").absolutePath
keystoreProperties["storePassword"] = ""
keystoreProperties["publishCredentialsFile"] = ""
}
android {
packaging {
resources {
excludes += listOf(
"META-INF/LICENSE-LGPL-2.1.txt",
"META-INF/LICENSE-LGPL-3.txt",
"META-INF/LICENSE-W3C-TEST",
"META-INF/LICENSE",
"META-INF/DEPENDENCIES"
)
}
}
compileSdk = libs.versions.maxSdk.get().toInt()
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = "11"
}
defaultConfig {
applicationId = "com.wbrawner.simplemarkdown"
minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.maxSdk.get().toInt()
versionCode = 45
versionName = "2024.10.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunnerArguments["clearPackageData"] = "true"
buildConfigField("boolean", "ENABLE_CUSTOM_CSS", "true")
}
signingConfigs {
create("playRelease") {
keyAlias = keystoreProperties["keyAlias"].toString()
keyPassword = keystoreProperties["keyPassword"].toString()
storeFile = file(keystoreProperties["storeFile"].toString())
storePassword = keystoreProperties["storePassword"].toString()
}
}
buildTypes {
release {
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
buildConfigField("boolean", "ENABLE_CUSTOM_CSS", "false")
}
}
flavorDimensions.add("platform")
productFlavors {
create("free") {
applicationIdSuffix = ".free"
versionNameSuffix = "-free"
}
create("play") {
signingConfig = signingConfigs["playRelease"]
}
}
testOptions {
unitTests {
isIncludeAndroidResources = true
}
execution = "ANDROIDX_TEST_ORCHESTRATOR"
}
namespace = "com.wbrawner.simplemarkdown"
buildFeatures {
compose = true
}
playConfigs {
register("play") {
enabled.set(true)
commit.set(true)
}
}
lint {
disable += listOf(
"AndroidGradlePluginVersion",
"GradleDependency",
"ObsoleteLintCustomCheck"
)
warningsAsErrors = true
}
}
play {
commit.set(false)
enabled.set(false)
track.set("production")
defaultToAppBundles.set(true)
(keystoreProperties["publishCredentialsFile"] as? String)?.ifBlank { null }?.let {
serviceAccountCredentials.set(file(it))
}
}
dependencies {
"freeImplementation"(project(":free"))
"playImplementation"(project(":non-free"))
implementation(libs.androidx.material3.windowsizeclass)
implementation(libs.androidx.navigation.compose)
testImplementation(libs.junit)
testRuntimeOnly(libs.robolectric)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.espresso.web)
androidTestImplementation(libs.androidx.espresso.intents)
androidTestRuntimeOnly(libs.androidx.runner)
androidTestUtil(libs.androidx.orchestrator)
implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.appcompat)
implementation(libs.material)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.browser)
implementation(libs.commonmark)
implementation(libs.commonmark.ext.gfm.tables)
implementation(libs.commonmark.ext.gfm.strikethrough)
implementation(libs.commonmark.ext.autolink)
implementation(libs.commonmark.ext.task.list.items)
implementation(libs.commonmark.ext.yaml.front.matter)
implementation(libs.commonmark.ext.image.attributes)
implementation(libs.commonmark.ext.heading.anchor)
val composeBom = enforcedPlatform(libs.compose.bom)
implementation(composeBom)
androidTestImplementation(composeBom)
implementation(libs.androidx.runtime)
implementation(libs.androidx.ui)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.foundation)
implementation(libs.androidx.foundation.layout)
implementation(libs.androidx.ui.tooling)
implementation(libs.androidx.material3)
implementation(libs.androidx.material.icons.extended)
androidTestImplementation(libs.androidx.ui.test.junit4)
runtimeOnly(libs.kotlinx.coroutines.android)
testImplementation(libs.kotlinx.coroutines.test)
implementation(libs.syllable.counter)
androidTestImplementation(libs.androidx.ui.test)
androidTestImplementation(libs.androidx.core)
androidTestImplementation(libs.androidx.monitor)
androidTestImplementation(libs.junit)
implementation(libs.androidx.activity.ktx)
implementation(libs.androidx.activity.ktx)
implementation(libs.androidx.animation.core)
implementation(libs.androidx.animation)
implementation(libs.androidx.material.icons.core)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.text)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.ui.unit)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.navigation.common)
implementation(libs.androidx.navigation.runtime.ktx)
implementation(libs.androidx.preference.ktx)
implementation(libs.acra.core)
implementation(libs.timber)
implementation(libs.kotlinx.coroutines.core)
implementation(project(":core"))
}
fladle {
variant.set("playDebug")
useOrchestrator.set(true)
environmentVariables.put("clearPackageData", "true")
testTimeout.set("7m")
devices.add(
mapOf("model" to "Pixel2.arm", "version" to "33")
)
projectId.set("simplemarkdown")
}
tasks.register<Exec>("pullLogFiles") {
commandLine = listOf(
"adb", "pull",
"/storage/emulated/0/Android/data/com.wbrawner.simplemarkdown/files/logs"
)
}

78
app/google-services.json Normal file
View file

@ -0,0 +1,78 @@
{
"project_info": {
"project_number": "318641233555",
"firebase_url": "https://simplemarkdown.firebaseio.com",
"project_id": "simplemarkdown",
"storage_bucket": "simplemarkdown.appspot.com"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:318641233555:android:09d865cad87e3b5f",
"android_client_info": {
"package_name": "com.wbrawner.simplemarkdown"
}
},
"oauth_client": [
{
"client_id": "318641233555-83n2k1mqhokf0b7lhccqiva9pspgripq.apps.googleusercontent.com",
"client_type": 1,
"android_info": {
"package_name": "com.wbrawner.simplemarkdown",
"certificate_hash": "710e37c230689bc259fc6e2e032e2f56956f8d33"
}
},
{
"client_id": "318641233555-a5rm2cqqf66jc9j5nmg3ttepdt2iaeno.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "AIzaSyBDMcXg-10NsXLDKJRtj5WnXoHrwg3m9Os"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": [
{
"client_id": "318641233555-a5rm2cqqf66jc9j5nmg3ttepdt2iaeno.apps.googleusercontent.com",
"client_type": 3
}
]
}
},
"admob_app_id": "ca-app-pub-3319579963502409~4576405307"
},
{
"client_info": {
"mobilesdk_app_id": "1:318641233555:android:5dfb62206717437e",
"android_client_info": {
"package_name": "com.wbrawner.simplemarkdown.samsung"
}
},
"oauth_client": [
{
"client_id": "318641233555-a5rm2cqqf66jc9j5nmg3ttepdt2iaeno.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "AIzaSyBDMcXg-10NsXLDKJRtj5WnXoHrwg3m9Os"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": [
{
"client_id": "318641233555-a5rm2cqqf66jc9j5nmg3ttepdt2iaeno.apps.googleusercontent.com",
"client_type": 3
}
]
}
}
}
],
"configuration_version": "1"
}

View file

@ -2,7 +2,7 @@
# By default, the flags in this file are appended to flags specified
# in /home/billy/Android/Sdk/tools/proguard/proguard-android.txt
# You can edit the include path and order by changing the proguardFiles
# directive in build.gradle.kts.
# directive in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
@ -27,15 +27,4 @@
### Crashlytics ###
-keepattributes *Annotation*
-keepattributes SourceFile,LineNumberTable
-keep public class * extends java.lang.Exception
### Crashlytics ###
-dontwarn org.bouncycastle.jsse.BCSSLParameters
-dontwarn org.bouncycastle.jsse.BCSSLSocket
-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider
-dontwarn org.conscrypt.Conscrypt$Version
-dontwarn org.conscrypt.Conscrypt
-dontwarn org.conscrypt.ConscryptHostnameVerifier
-dontwarn org.openjsse.javax.net.ssl.SSLParameters
-dontwarn org.openjsse.javax.net.ssl.SSLSocket
-dontwarn org.openjsse.net.ssl.OpenJSSE
### End Crashlytics ###

View file

@ -0,0 +1,182 @@
package com.wbrawner.simplemarkdown
import android.app.Activity.RESULT_OK
import android.app.Instrumentation
import android.content.Context
import android.content.Intent
import android.content.pm.ActivityInfo
import android.net.Uri
import android.view.KeyEvent
import androidx.core.content.FileProvider
import androidx.test.InstrumentationRegistry
import androidx.test.InstrumentationRegistry.getInstrumentation
import androidx.test.core.app.ApplicationProvider.getApplicationContext
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.Espresso.openActionBarOverflowOrOptionsMenu
import androidx.test.espresso.action.ViewActions.*
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.intent.Intents.intending
import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction
import androidx.test.espresso.intent.rule.IntentsTestRule
import androidx.test.espresso.matcher.ViewMatchers.*
import androidx.test.espresso.web.assertion.WebViewAssertions.webMatches
import androidx.test.espresso.web.sugar.Web.onWebView
import androidx.test.espresso.web.webdriver.DriverAtoms.findElement
import androidx.test.espresso.web.webdriver.DriverAtoms.getText
import androidx.test.espresso.web.webdriver.Locator
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiScrollable
import androidx.test.uiautomator.UiSelector
import com.wbrawner.simplemarkdown.view.activity.MainActivity
import org.hamcrest.Matchers.containsString
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.io.File
import java.io.Reader
class MarkdownTests {
@get:Rule
var activityRule = IntentsTestRule(MainActivity::class.java, false, false)
lateinit var file: File
@Before
fun setup() {
file = File(getApplicationContext<Context>().filesDir.absolutePath + "/tmp", "temp.md")
file.parentFile?.mkdirs()
file.delete()
activityRule.launchActivity(null)
activityRule.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
@Test
@Throws(Exception::class)
fun openAppTest() {
val mDevice = UiDevice.getInstance(getInstrumentation())
mDevice.pressHome()
// Bring up the default launcher by searching for a UI component
// that matches the content description for the launcher button.
val allAppsButton = mDevice
.findObject(UiSelector().description("Apps"))
// Perform a click on the button to load the launcher.
allAppsButton.clickAndWaitForNewWindow()
// Context of the app under test.
val appContext = InstrumentationRegistry.getTargetContext()
assertEquals("com.wbrawner.simplemarkdown", appContext.packageName)
val appView = UiScrollable(UiSelector().scrollable(true))
val simpleMarkdownSelector = UiSelector()
.text(getApplicationContext<Context>().getString(R.string.app_name_short))
appView.scrollIntoView(simpleMarkdownSelector)
mDevice.findObject(simpleMarkdownSelector).clickAndWaitForNewWindow()
}
@Test
fun editAndPreviewMarkdownTest() {
onView(withId(R.id.markdown_edit)).perform(typeText("# Header test"))
onView(withText(R.string.action_preview)).perform(click())
onWebView(withId(R.id.markdown_view)).forceJavascriptEnabled()
.withElement(findElement(Locator.TAG_NAME, "h1"))
.check(webMatches(getText(), containsString("Header test")))
}
@Test
fun newMarkdownTest() {
onView(withId(R.id.markdown_edit))
.perform(typeText("# UI Testing\n\nThe quick brown fox jumped over the lazy dog."))
openActionBarOverflowOrOptionsMenu(getApplicationContext())
onView(withText(R.string.action_new)).perform(click())
onView(withText(R.string.action_discard)).perform(click())
onView(withId(R.id.markdown_edit)).check(matches(withText("")))
}
@Test
fun saveMarkdownWithFileUriTest() {
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
onView(withId(R.id.markdown_edit)).perform(typeText(markdownText))
val activityResult = Instrumentation.ActivityResult(RESULT_OK, Intent().apply {
data = Uri.fromFile(file)
})
intending(hasAction(Intent.ACTION_CREATE_DOCUMENT)).respondWith(activityResult)
openActionBarOverflowOrOptionsMenu(getApplicationContext())
onView(withText(R.string.action_save_as)).perform(click())
Thread.sleep(500)
assertEquals(markdownText, file.inputStream().reader().use(Reader::readText))
onView(withText("temp.md")).check(matches(withParent(withId(R.id.toolbar))))
}
@Test
fun saveMarkdownWithContentUriTest() {
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
onView(withId(R.id.markdown_edit)).perform(typeText(markdownText))
val activityResult = Instrumentation.ActivityResult(RESULT_OK, Intent().apply {
data = FileProvider.getUriForFile(getApplicationContext(), "com.wbrawner.simplemarkdown.fileprovider", file)
})
intending(hasAction(Intent.ACTION_CREATE_DOCUMENT)).respondWith(activityResult)
openActionBarOverflowOrOptionsMenu(getApplicationContext())
onView(withText(R.string.action_save_as)).perform(click())
Thread.sleep(500)
assertEquals(markdownText, file.inputStream().reader().use(Reader::readText))
onView(withText("temp.md")).check(matches(withParent(withId(R.id.toolbar))))
}
@Test
fun loadMarkdownWithFileUriTest() {
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
file.outputStream().writer().use { it.write(markdownText) }
val activityResult = Instrumentation.ActivityResult(RESULT_OK, Intent().apply {
data = Uri.fromFile(file)
})
intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWith(activityResult)
openActionBarOverflowOrOptionsMenu(getApplicationContext())
onView(withText(R.string.action_open)).perform(click())
Thread.sleep(500)
onView(withId(R.id.markdown_edit)).check(matches(withText(markdownText)))
onView(withText("temp.md")).check(matches(withParent(withId(R.id.toolbar))))
}
@Test
fun loadMarkdownWithContentUriTest() {
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
file.outputStream().writer().use { it.write(markdownText) }
val activityResult = Instrumentation.ActivityResult(RESULT_OK, Intent().apply {
data = FileProvider.getUriForFile(getApplicationContext(), "com.wbrawner.simplemarkdown.fileprovider", file)
})
intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWith(activityResult)
openActionBarOverflowOrOptionsMenu(getApplicationContext())
onView(withText(R.string.action_open)).perform(click())
Thread.sleep(500)
onView(withId(R.id.markdown_edit)).check(matches(withText(markdownText)))
onView(withText("temp.md")).check(matches(withParent(withId(R.id.toolbar))))
}
@Test
fun openEditAndSaveMarkdownTest() {
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
file.outputStream().writer().use { it.write(markdownText) }
val activityResult = Instrumentation.ActivityResult(RESULT_OK, Intent().apply {
data = Uri.fromFile(file)
})
intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWith(activityResult)
openActionBarOverflowOrOptionsMenu(getApplicationContext())
onView(withText(R.string.action_open)).perform(click())
Thread.sleep(500)
onView(withId(R.id.markdown_edit)).check(matches(withText(markdownText)))
onView(withText("temp.md")).check(matches(withParent(withId(R.id.toolbar))))
val additionalText = "# More info\n\nThis is some additional text"
onView(withId(R.id.markdown_edit)).perform(
clearText(),
typeText(additionalText)
)
openActionBarOverflowOrOptionsMenu(getApplicationContext())
onView(withText(R.string.action_save)).perform(click())
Thread.sleep(500)
onView(withText(getApplicationContext<Context>().getString(R.string.file_saved, "temp.md")))
assertEquals(additionalText, file.inputStream().reader().use(Reader::readText))
onView(withText("temp.md")).check(matches(withParent(withId(R.id.toolbar))))
}
}

View file

@ -1,27 +0,0 @@
package com.wbrawner.simplemarkdown
import androidx.compose.ui.test.junit4.createEmptyComposeRule
import androidx.test.core.app.ActivityScenario
import com.wbrawner.simplemarkdown.robot.onMainScreen
import org.junit.Rule
import org.junit.Test
class HelpTest {
@get:Rule
val composeRule = createEmptyComposeRule()
@Test
fun openHelpPageTest() {
ActivityScenario.launch(MainActivity::class.java)
onMainScreen(composeRule) {
checkTitleEquals("Untitled.md")
checkMarkdownEquals("")
openDrawer()
} onNavigationDrawer {
openHelpPage()
} onHelpScreen {
checkTitleEquals("Help")
verifyH1("Headings/Titles")
}
}
}

View file

@ -1,254 +0,0 @@
package com.wbrawner.simplemarkdown
import android.app.Activity.RESULT_OK
import android.app.Instrumentation
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.compose.ui.test.junit4.createEmptyComposeRule
import androidx.core.content.FileProvider
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider.getApplicationContext
import androidx.test.espresso.intent.Intents.intending
import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction
import androidx.test.espresso.intent.rule.IntentsRule
import androidx.test.espresso.matcher.ViewMatchers.*
import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
import com.wbrawner.simplemarkdown.robot.onMainScreen
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.io.File
import java.io.Reader
class MarkdownTests {
@get:Rule
val composeRule = createEmptyComposeRule()
@get:Rule
val intentsRule = IntentsRule()
private lateinit var file: File
@Before
fun setup() {
file = File(getApplicationContext<Context>().filesDir.absolutePath + "/tmp", "temp.md")
assertTrue(requireNotNull(file.parentFile).mkdirs())
file.delete()
}
@Test
@Throws(Exception::class)
fun openAppTest() {
val context = getInstrumentation().targetContext
context.packageManager
.getLaunchIntentForPackage(context.packageName)
.apply { context.startActivity(this) }
}
@Test
fun editAndPreviewMarkdownTest() {
ActivityScenario.launch(MainActivity::class.java)
onMainScreen(composeRule) {
typeMarkdown("# Header test")
checkMarkdownEquals("# Header test")
openPreview()
} onPreview {
verifyH1("Header test")
}
}
@Test
fun openThenNewMarkdownTest() {
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
file.outputStream().writer().use { it.write(markdownText) }
val activityResult = Instrumentation.ActivityResult(RESULT_OK, Intent().apply {
data = Uri.fromFile(file)
addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
})
intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWith(activityResult)
ActivityScenario.launch(MainActivity::class.java)
onMainScreen(composeRule) {
openMenu()
clickOpenMenuItem()
checkMarkdownEquals(markdownText)
openMenu()
clickNewMenuItem()
verifyDialogIsNotShown()
checkMarkdownEquals("")
}
}
@Test
fun editThenNewMarkdownTest() {
ActivityScenario.launch(MainActivity::class.java)
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
onMainScreen(composeRule) {
typeMarkdown(markdownText)
openMenu()
clickNewMenuItem()
verifyDialogIsShown("Would you like to save your changes?")
discardChanges()
checkMarkdownEquals("")
}
}
@Test
fun saveMarkdownWithFileUriTest() = runTest {
ActivityScenario.launch(MainActivity::class.java)
onMainScreen(composeRule) {
checkTitleEquals("Untitled.md")
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
typeMarkdown(markdownText)
val activityResult = Instrumentation.ActivityResult(RESULT_OK, Intent().apply {
data = Uri.fromFile(file)
})
intending(hasAction(Intent.ACTION_CREATE_DOCUMENT)).respondWith(activityResult)
openMenu()
clickSaveMenuItem()
awaitIdle()
assertEquals(markdownText, file.inputStream().reader().use(Reader::readText))
checkTitleEquals("temp.md")
}
}
@Test
fun saveMarkdownWithContentUriTest() = runTest {
ActivityScenario.launch(MainActivity::class.java)
onMainScreen(composeRule) {
checkTitleEquals("Untitled.md")
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
typeMarkdown(markdownText)
val activityResult = Instrumentation.ActivityResult(RESULT_OK, Intent().apply {
data = FileProvider.getUriForFile(
getApplicationContext(),
"${BuildConfig.APPLICATION_ID}.fileprovider",
file
)
})
intending(hasAction(Intent.ACTION_CREATE_DOCUMENT)).respondWith(activityResult)
openMenu()
clickSaveMenuItem()
awaitIdle()
assertEquals(markdownText, file.inputStream().reader().use(Reader::readText))
checkTitleEquals("temp.md")
}
}
@Test
fun loadMarkdownWithFileUriTest() = runTest {
ActivityScenario.launch(MainActivity::class.java)
onMainScreen(composeRule) {
checkTitleEquals("Untitled.md")
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
file.outputStream().writer().use { it.write(markdownText) }
val activityResult = Instrumentation.ActivityResult(RESULT_OK, Intent().apply {
data = Uri.fromFile(file)
})
intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWith(activityResult)
openMenu()
clickOpenMenuItem()
awaitIdle()
checkMarkdownEquals(markdownText)
checkTitleEquals("temp.md")
}
}
@Test
fun loadMarkdownWithContentUriTest() = runTest {
ActivityScenario.launch(MainActivity::class.java)
onMainScreen(composeRule) {
checkTitleEquals("Untitled.md")
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
file.outputStream().writer().use { it.write(markdownText) }
val activityResult = Instrumentation.ActivityResult(RESULT_OK, Intent().apply {
data = FileProvider.getUriForFile(
getApplicationContext(),
"${BuildConfig.APPLICATION_ID}.fileprovider",
file
)
})
intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWith(activityResult)
openMenu()
clickOpenMenuItem()
awaitIdle()
checkMarkdownEquals(markdownText)
checkTitleEquals("temp.md")
}
}
@Test
fun launchWithContentUriTest() = runTest {
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
file.outputStream().writer().use { it.write(markdownText) }
val fileUri = FileProvider.getUriForFile(
getApplicationContext(),
"${BuildConfig.APPLICATION_ID}.fileprovider",
file
)
ActivityScenario.launch<MainActivity>(
Intent(
Intent.ACTION_VIEW,
fileUri,
getInstrumentation().targetContext,
MainActivity::class.java
)
)
onMainScreen(composeRule) {
awaitIdle()
checkMarkdownEquals(markdownText)
checkTitleEquals("temp.md")
}
}
@Test
fun openEditAndSaveMarkdownTest() = runTest {
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
file.outputStream().writer().use { it.write(markdownText) }
val activityResult = Instrumentation.ActivityResult(RESULT_OK, Intent().apply {
data = Uri.fromFile(file)
})
intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWith(activityResult)
ActivityScenario.launch(MainActivity::class.java)
onMainScreen(composeRule) {
checkTitleEquals("Untitled.md")
openMenu()
clickOpenMenuItem()
awaitIdle()
verifyTextIsShown("Successfully loaded temp.md")
checkMarkdownEquals(markdownText)
checkTitleEquals("temp.md")
val additionalText = "# More info\n\nThis is some additional text"
typeMarkdown(additionalText)
openMenu()
clickSaveMenuItem()
awaitIdle()
verifyTextIsShown("Successfully saved temp.md")
assertEquals(additionalText, file.inputStream().reader().use(Reader::readText))
checkTitleEquals("temp.md")
}
}
@Test
fun editAndViewHelpMarkdownTest() = runTest {
ActivityScenario.launch(MainActivity::class.java)
onMainScreen(composeRule) {
checkTitleEquals("Untitled.md")
typeMarkdown("# Header test")
checkMarkdownEquals("# Header test")
openDrawer()
} onNavigationDrawer {
openHelpPage()
} onHelpScreen {
checkTitleEquals("Help")
verifyH1("Headings/Titles")
pressBack()
} onMainScreen {
checkMarkdownEquals("# Header test")
}
}
}

View file

@ -1,29 +0,0 @@
package com.wbrawner.simplemarkdown
import androidx.compose.ui.test.SemanticsNodeInteraction
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotDisplayed
private const val ASSERTION_TIMEOUT = 5_000L
fun SemanticsNodeInteraction.waitUntilIsDisplayed() = waitUntil {
assertIsDisplayed()
}
fun SemanticsNodeInteraction.waitUntilIsNotDisplayed() = waitUntil {
assertIsNotDisplayed()
}
fun <T> SemanticsNodeInteraction.waitUntil(assertion: SemanticsNodeInteraction.() -> T): T {
val start = System.currentTimeMillis()
lateinit var assertionError: AssertionError
while (System.currentTimeMillis() - start < ASSERTION_TIMEOUT) {
try {
return assertion()
} catch (e: AssertionError) {
assertionError = e
Thread.sleep(10)
}
}
throw assertionError
}

View file

@ -1,80 +0,0 @@
package com.wbrawner.simplemarkdown.robot
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.getOrNull
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.hasAnyDescendant
import androidx.compose.ui.test.hasClickAction
import androidx.compose.ui.test.hasContentDescription
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextReplacement
import com.wbrawner.simplemarkdown.waitUntil
import com.wbrawner.simplemarkdown.waitUntilIsDisplayed
import com.wbrawner.simplemarkdown.waitUntilIsNotDisplayed
import kotlinx.coroutines.CoroutineScope
fun onMainScreen(composeRule: ComposeTestRule, block: MainScreenRobot.() -> Unit) =
MainScreenRobot(composeRule).apply(block)
@Suppress("UnusedReceiverParameter") // Used to avoid import ambiguity for tests
suspend fun CoroutineScope.onMainScreen(
composeRule: ComposeTestRule,
block: suspend MainScreenRobot.() -> Unit
): MainScreenRobot {
val mainScreenRobot = MainScreenRobot(composeRule)
block.invoke(mainScreenRobot)
return mainScreenRobot
}
class MainScreenRobot(private val composeRule: ComposeTestRule) :
TopAppBarRobot by ComposeTopAppBarRobot(composeRule) {
fun typeMarkdown(markdown: String) = composeRule.onNode(hasSetTextAction())
.performTextReplacement(markdown)
fun checkMarkdownEquals(markdown: String) {
val markdownMatcher = SemanticsMatcher("Markdown = [$markdown]") {
it.config.getOrNull(SemanticsProperties.EditableText)?.text == markdown
}
composeRule.onNode(hasSetTextAction()).waitUntil {
assert(markdownMatcher)
}
}
fun openPreview() = composeRule.onNodeWithText("Preview").performClick()
fun openMenu() = composeRule.onNodeWithContentDescription("Editor Actions").performClick()
fun clickOpenMenuItem() = composeRule.onNodeWithText("Open").performClick()
fun clickNewMenuItem() = composeRule.onNodeWithText("New").performClick()
fun clickSaveMenuItem() = composeRule.onNodeWithText("Save").performClick()
fun verifyDialogIsShown(text: String) =
composeRule.onNode(isDialog().and(hasAnyDescendant(hasText(text)))).waitUntilIsDisplayed()
fun verifyDialogIsNotShown() = composeRule.onNode(isDialog()).waitUntilIsNotDisplayed()
fun discardChanges() = composeRule.onNodeWithText("No").performClick()
fun verifyTextIsShown(text: String) = composeRule.onNodeWithText(text).waitUntilIsDisplayed()
fun openDrawer() = composeRule.onNode(hasClickAction() and hasContentDescription("Main Menu"))
.waitUntilIsDisplayed()
.performClick()
suspend fun awaitIdle() = composeRule.awaitIdle()
infix fun onPreview(block: WebViewRobot.() -> Unit) = EspressoWebViewRobot().apply(block)
infix fun onNavigationDrawer(block: NavigationDrawerRobot.() -> Unit): NavigationDrawerRobot =
NavigationDrawerRobot(composeRule).apply(block = block)
}

View file

@ -1,14 +0,0 @@
package com.wbrawner.simplemarkdown.robot
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.performClick
class MarkdownInfoScreenRobot(private val composeTestRule: ComposeTestRule) :
TopAppBarRobot by ComposeTopAppBarRobot(composeTestRule),
WebViewRobot by EspressoWebViewRobot() {
fun pressBack() = composeTestRule.onNodeWithContentDescription("Back").performClick()
infix fun onMainScreen(block: MainScreenRobot.() -> Unit) =
MainScreenRobot(composeTestRule).apply(block)
}

View file

@ -1,16 +0,0 @@
package com.wbrawner.simplemarkdown.robot
import androidx.compose.ui.test.hasClickAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.compose.ui.test.performClick
import com.wbrawner.simplemarkdown.waitUntilIsDisplayed
class NavigationDrawerRobot(private val composeTestRule: ComposeTestRule) {
fun openHelpPage() = composeTestRule.onNode(hasClickAction() and hasText("Help"))
.waitUntilIsDisplayed()
.performClick()
infix fun onHelpScreen(block: MarkdownInfoScreenRobot.() -> Unit) =
MarkdownInfoScreenRobot(composeTestRule).apply(block)
}

View file

@ -1,24 +0,0 @@
package com.wbrawner.simplemarkdown.robot
import androidx.compose.ui.test.SemanticsNodeInteraction
import androidx.compose.ui.test.hasAnySibling
import androidx.compose.ui.test.hasContentDescription
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.ComposeTestRule
import com.wbrawner.simplemarkdown.waitUntilIsDisplayed
interface TopAppBarRobot {
fun checkTitleEquals(title: String): SemanticsNodeInteraction
}
class ComposeTopAppBarRobot(private val composeTestRule: ComposeTestRule) : TopAppBarRobot {
override fun checkTitleEquals(title: String) =
composeTestRule.onNode(
hasAnySibling(
hasContentDescription("Main Menu") or hasContentDescription(
"Back"
)
).and(hasText(title))
)
.waitUntilIsDisplayed()
}

View file

@ -1,24 +0,0 @@
package com.wbrawner.simplemarkdown.robot
import android.webkit.WebView
import androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom
import androidx.test.espresso.web.assertion.WebViewAssertions.webMatches
import androidx.test.espresso.web.sugar.Web.onWebView
import androidx.test.espresso.web.webdriver.DriverAtoms.findElement
import androidx.test.espresso.web.webdriver.DriverAtoms.getText
import androidx.test.espresso.web.webdriver.Locator
import org.hamcrest.CoreMatchers.containsString
interface WebViewRobot {
fun verifyH1(text: String)
}
class EspressoWebViewRobot : WebViewRobot {
private fun findWebView() = onWebView(isAssignableFrom(WebView::class.java))
.forceJavascriptEnabled()
override fun verifyH1(text: String) {
findWebView().withElement(findElement(Locator.TAG_NAME, "h1"))
.check(webMatches(getText(), containsString(text)))
}
}

View file

@ -0,0 +1,9 @@
package com.wbrawner.simplemarkdown.utility
import android.app.Activity
import androidx.lifecycle.MutableLiveData
import com.google.android.material.button.MaterialButton
class SupportLinkProvider(@Suppress("unused") private val activity: Activity) {
val supportLinks = MutableLiveData<List<MaterialButton>>()
}

View file

@ -1,23 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
xmlns:tools="http://schemas.android.com/tools"
package="com.wbrawner.simplemarkdown">
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="com.android.vending.BILLING" />
<application
android:name=".MarkdownApplication"
android:allowBackup="true"
android:enableOnBackInvokedCallback="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:resizeableActivity="true"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.App.Starting"
android:theme="@style/AppTheme"
tools:ignore="AllowBackup"
tools:targetApi="tiramisu">
<activity android:name=".MainActivity"
android:exported="true"
tools:targetApi="n">
<activity
android:name=".view.activity.SplashActivity"
android:theme="@style/AppTheme.Splash"
android:label="@string/app_name_short">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@ -39,7 +42,7 @@
<data android:host="*" />
</intent-filter>
</activity>
<activity android:name=".view.activity.MainActivity" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
@ -49,6 +52,13 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<meta-data
android:name="io.sentry.dsn"
android:value="${sentryDsn}" />
<meta-data
android:name="io.sentry.auto-init"
android:value="false" />
</application>
</manifest>

View file

@ -90,11 +90,11 @@ data 1|data 2|data 3
data 1|data 2|data 3
```
| Left Content | Center Content | Right Content |
|:-------------|:--------------:|--------------:|
| data 1 | data 2 | data 3 |
| data 1 | data 2 | data 3 |
| data 1 | data 2 | data 3 |
Left Content|Center Content|Right Content
:--------|:--------:|--------:
data 1|data 2|data 3
data 1|data 2|data 3
data 1|data 2|data 3
### Images
@ -116,7 +116,7 @@ In addition to the monospace inline element, code blocks can be created by inden
Or by wrapping the code in three backticks (\`\`\`):
```javascript
```
function helloWorld() {
console.log("Hello, world!")
}

View file

@ -1,10 +1,10 @@
The internet access permission is requested primarily for retrieving images from the internet in
case you embed them in your markdown, but it also allows me to send automated error and crash
reports to myself whenever the app runs into an issue. These error reports are opt-out, and are
powered by [Firebase Crashlytics](https://firebase.google.com/docs/crashlytics/), which is a
free error reporting solution provided by Google. These error reports are used exclusively for
fixing problems that occur while you're using the app, along with some analytics info like how
long you use the app for, how often, and which features of the app you use. This helps me to
determine how to spend my very limited time on building out new features. I'll have to defer to
[Google's Privacy Policy](https://policies.google.com/privacy) to explain how they handle the
data. As for me, I don't knowingly or willingly sell or share your data.
First and foremost, Simple Markdown DOES NOT collect any personally identifiable information. The
internet access permission is requested primarily for retrieving images from the internet in
case you embed them in your markdown, but it also allows me to send automated error and crash
reports to myself whenever the app runs into an issue. These automated reports are powered by my own
self-hosted version of [Sentry] (https://sentry.io/), which is a free and open source error
reporting solution. These error reports are used exclusively for fixing problems that occur while
you're using the app. For more information on the kinds of data that may be sent in these automated
error reports, please see the [relevant documentation](https://docs.sentry.io/platforms/android/#context)
on Sentry's website. If you would like to opt-out of these error reports, please visit the in-app
settings page to disable the toggle for error reports.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View file

@ -1,187 +0,0 @@
package com.wbrawner.simplemarkdown
import android.app.ComponentCaller
import android.content.Intent
import android.os.Build
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.annotation.StringRes
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.core.EaseIn
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.scaleOut
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Help
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.PrivacyTip
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.core.app.ActivityCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat
import androidx.lifecycle.lifecycleScope
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.wbrawner.simplemarkdown.MarkdownApplication.Companion.fileHelper
import com.wbrawner.simplemarkdown.MarkdownApplication.Companion.preferenceHelper
import com.wbrawner.simplemarkdown.ui.MainScreen
import com.wbrawner.simplemarkdown.ui.MarkdownInfoScreen
import com.wbrawner.simplemarkdown.ui.SettingsScreen
import com.wbrawner.simplemarkdown.ui.SupportScreen
import com.wbrawner.simplemarkdown.ui.theme.SimpleMarkdownTheme
import com.wbrawner.simplemarkdown.utility.Preference
import kotlinx.coroutines.launch
import org.acra.ACRA
class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsResultCallback {
private val viewModel: MarkdownViewModel by viewModels {
MarkdownViewModel.factory(
fileHelper,
preferenceHelper
)
}
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
setContent {
val autosaveEnabled by preferenceHelper.observe<Boolean>(Preference.AUTOSAVE_ENABLED)
.collectAsState()
val darkModePreference by preferenceHelper.observe<String>(Preference.DARK_MODE)
.collectAsState()
LaunchedEffect(darkModePreference) {
val darkMode = when {
darkModePreference.equals(
getString(R.string.pref_value_light),
ignoreCase = true
) -> AppCompatDelegate.MODE_NIGHT_NO
darkModePreference.equals(
getString(R.string.pref_value_dark),
ignoreCase = true
) -> AppCompatDelegate.MODE_NIGHT_YES
else -> {
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY
} else {
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
}
}
}
AppCompatDelegate.setDefaultNightMode(darkMode)
}
val errorReporterPreference by preferenceHelper.observe<Boolean>(Preference.ERROR_REPORTS_ENABLED)
.collectAsState()
LaunchedEffect(errorReporterPreference) {
ACRA.errorReporter.setEnabled(errorReporterPreference)
}
val intentData = remember(intent) { intent?.data }
LaunchedEffect(intentData) {
viewModel.load(intentData?.toString())
}
val windowSizeClass = calculateWindowSizeClass(this)
SimpleMarkdownTheme {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = Route.EDITOR.path,
enterTransition = {
fadeIn(
animationSpec = tween(
300, easing = LinearEasing
)
) + slideIntoContainer(
animationSpec = tween(300, easing = EaseIn),
towards = AnimatedContentTransitionScope.SlideDirection.Start
)
},
popEnterTransition = { fadeIn() },
popExitTransition = {
scaleOut(targetScale = 0.9f) + slideOutOfContainer(
animationSpec = tween(300, easing = EaseIn),
towards = AnimatedContentTransitionScope.SlideDirection.End
)
}
) {
composable(Route.EDITOR.path) {
MainScreen(
navController = navController,
viewModel = viewModel,
enableWideLayout = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Expanded,
enableAutosave = autosaveEnabled,
)
}
composable(Route.SETTINGS.path) {
SettingsScreen(navController = navController, preferenceHelper)
}
composable(Route.SUPPORT.path) {
SupportScreen(navController = navController)
}
composable(Route.HELP.path) {
MarkdownInfoScreen(
title = stringResource(Route.HELP.title),
file = "Cheatsheet.md",
navController = navController
)
}
composable(Route.ABOUT.path) {
MarkdownInfoScreen(
title = stringResource(Route.ABOUT.title),
file = "Libraries.md",
navController = navController
)
}
composable(Route.PRIVACY.path) {
MarkdownInfoScreen(
title = stringResource(Route.PRIVACY.title),
file = "Privacy Policy.md",
navController = navController
)
}
}
}
}
}
override fun onNewIntent(intent: Intent, caller: ComponentCaller) {
super.onNewIntent(intent, caller)
lifecycleScope.launch {
intent.data?.let {
viewModel.load(it.toString())
}
}
}
}
enum class Route(
val path: String,
@StringRes
val title: Int,
val icon: ImageVector
) {
EDITOR("/", R.string.title_editor, Icons.Default.Edit),
SETTINGS("/settings", R.string.title_settings, Icons.Default.Settings),
SUPPORT("/support", R.string.support_title, Icons.Default.Favorite),
HELP("/help", R.string.title_help, Icons.AutoMirrored.Filled.Help),
ABOUT("/about", R.string.title_about, Icons.Default.Info),
PRIVACY("/privacy", R.string.action_privacy, Icons.Default.PrivacyTip),
}

View file

@ -2,24 +2,19 @@ package com.wbrawner.simplemarkdown
import android.app.Application
import android.os.StrictMode
import com.wbrawner.simplemarkdown.core.ErrorReporterTree
import com.wbrawner.simplemarkdown.utility.AndroidFileHelper
import com.wbrawner.simplemarkdown.utility.AndroidPreferenceHelper
import com.wbrawner.simplemarkdown.utility.FileHelper
import com.wbrawner.simplemarkdown.utility.PersistentTree
import com.wbrawner.simplemarkdown.utility.PreferenceHelper
import com.wbrawner.simplemarkdown.utility.ReviewHelper
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import timber.log.Timber
import java.io.File
import androidx.preference.PreferenceManager
import com.wbrawner.simplemarkdown.utility.ErrorHandler
import com.wbrawner.simplemarkdown.utility.SentryErrorHandler
class MarkdownApplication : Application() {
private val coroutineScope = CoroutineScope(Dispatchers.Default)
val errorHandler: ErrorHandler by lazy {
SentryErrorHandler()
}
override fun onCreate() {
val enableErrorReports = PreferenceManager.getDefaultSharedPreferences(this)
.getBoolean(getString(R.string.error_reports_enabled), true)
errorHandler.init(this, enableErrorReports)
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder()
.detectAll()
@ -29,28 +24,7 @@ class MarkdownApplication : Application() {
.detectAll()
.penaltyLog()
.build())
Timber.plant(Timber.DebugTree())
coroutineScope.launch {
try {
Timber.plant(PersistentTree.create(coroutineScope, File(getExternalFilesDir(null), "logs")))
} catch (e: Exception) {
Timber.e(e, "Unable to create PersistentTree")
}
}
}
coroutineScope.launch {
Timber.plant(ErrorReporterTree.create(this@MarkdownApplication))
}
super.onCreate()
ReviewHelper.init(this)
fileHelper = AndroidFileHelper(this)
preferenceHelper = AndroidPreferenceHelper(this)
}
companion object {
lateinit var fileHelper: FileHelper
private set
lateinit var preferenceHelper: PreferenceHelper
private set
}
}

View file

@ -1,321 +0,0 @@
package com.wbrawner.simplemarkdown
import androidx.annotation.StringRes
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.CreationExtras
import com.wbrawner.simplemarkdown.core.LocalOnlyException
import com.wbrawner.simplemarkdown.model.Readability
import com.wbrawner.simplemarkdown.utility.FileHelper
import com.wbrawner.simplemarkdown.utility.Preference
import com.wbrawner.simplemarkdown.utility.PreferenceHelper
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.File
import java.net.URI
data class EditorState(
val fileName: String = "Untitled.md",
val markdown: TextFieldValue = TextFieldValue(""),
val path: URI? = null,
val toast: ParameterizedText? = null,
val alert: AlertDialogModel? = null,
val saveCallback: (() -> Unit)? = null,
val lockSwiping: Boolean = false,
val enableReadability: Boolean = false,
val initialMarkdown: String = "",
) {
val dirty: Boolean
get() = markdown.text != initialMarkdown
}
class MarkdownViewModel(
private val fileHelper: FileHelper,
private val preferenceHelper: PreferenceHelper
) : ViewModel() {
private val _state = MutableStateFlow(EditorState())
val state = _state.asStateFlow()
private val saveMutex = Mutex()
init {
preferenceHelper.observe<Boolean>(Preference.LOCK_SWIPING)
.onEach {
updateState { copy(lockSwiping = it) }
}
.launchIn(viewModelScope)
preferenceHelper.observe<Boolean>(Preference.READABILITY_ENABLED)
.onEach {
updateState {
copy(
enableReadability = it,
markdown = markdown.copy(annotatedString = markdown.text.annotate(it)),
)
}
}
.launchIn(viewModelScope)
}
fun updateMarkdown(markdown: String?) = updateMarkdown(TextFieldValue(markdown.orEmpty()))
fun updateMarkdown(markdown: TextFieldValue) {
updateState {
copy(
markdown = markdown.copy(annotatedString = markdown.text.annotate(enableReadability)),
)
}
}
fun dismissToast() {
updateState { copy(toast = null) }
}
fun dismissAlert() {
updateState { copy(alert = null) }
}
private fun unsetSaveCallback() {
updateState { copy(saveCallback = null) }
}
suspend fun load(loadPath: String?) {
saveMutex.withLock {
val actualLoadPath = loadPath
?.ifBlank { null }
?: preferenceHelper[Preference.AUTOSAVE_URI]
?.let {
val autosaveUri = it as? String
if (autosaveUri.isNullOrBlank()) {
preferenceHelper[Preference.AUTOSAVE_URI] = null
null
} else {
Timber.d("Using uri from shared preferences: $it")
autosaveUri
}
} ?: return
Timber.d("Loading file at $actualLoadPath")
try {
val uri = URI.create(actualLoadPath)
fileHelper.open(uri)
?.let { (name, content) ->
updateState {
copy(
path = uri,
fileName = name,
markdown = TextFieldValue(content),
initialMarkdown = content,
toast = ParameterizedText(R.string.file_loaded, arrayOf(name))
)
}
preferenceHelper[Preference.AUTOSAVE_URI] = actualLoadPath
} ?: throw IllegalStateException("Opened file was null")
} catch (e: Exception) {
Timber.e(LocalOnlyException(e), "Failed to open file at path: $actualLoadPath")
updateState {
copy(
alert = AlertDialogModel(
text = ParameterizedText(R.string.file_load_error),
confirmButton = AlertDialogModel.ButtonModel(
ParameterizedText(R.string.ok),
onClick = ::dismissAlert
)
)
)
}
}
}
}
suspend fun save(savePath: URI? = null, interactive: Boolean = true): Boolean =
saveMutex.withLock {
val actualSavePath = savePath
?: _state.value.path
?: run {
Timber.w("Attempted to save file with empty path")
if (interactive) {
updateState {
copy(saveCallback = ::unsetSaveCallback)
}
}
return@withLock false
}
try {
Timber.i("Saving file to $actualSavePath...")
val currentState = _state.value
val name = fileHelper.save(actualSavePath, currentState.markdown.text)
updateState {
currentState.copy(
fileName = name,
path = actualSavePath,
initialMarkdown = currentState.markdown.text,
toast = if (interactive) ParameterizedText(
R.string.file_saved,
arrayOf(name)
) else null
)
}
Timber.i("Saved file $name to uri $actualSavePath")
Timber.i("Persisting autosave uri in shared prefs: $actualSavePath")
preferenceHelper[Preference.AUTOSAVE_URI] = actualSavePath
true
} catch (e: Exception) {
Timber.e(e, "Failed to save file to $actualSavePath")
updateState {
copy(
alert = AlertDialogModel(
text = ParameterizedText(R.string.file_save_error),
confirmButton = AlertDialogModel.ButtonModel(
text = ParameterizedText(R.string.ok),
onClick = ::dismissAlert
)
)
)
}
false
}
}
suspend fun autosave() {
val isAutoSaveEnabled = preferenceHelper[Preference.AUTOSAVE_ENABLED] as Boolean
if (!isAutoSaveEnabled) {
Timber.i("Ignoring autosave as autosave not enabled")
return
}
if (!_state.value.dirty) {
Timber.d("Ignoring autosave as contents haven't changed")
return
}
if (saveMutex.isLocked) {
Timber.i("Ignoring autosave since manual save is already in progress")
return
}
Timber.d("Performing autosave")
if (!save(interactive = false)) {
withContext(Dispatchers.IO) {
// The user has left the app, with autosave enabled, and we don't already have a
// Uri for them or for some reason we were unable to save to the original Uri. In
// this case, we need to just save to internal file storage so that we can recover
val file = File(fileHelper.defaultDirectory, _state.value.fileName).toURI()
Timber.i("No cached uri for autosave, saving to $file instead")
// Here we call the fileHelper directly so that the file is still registered as dirty.
// This prevents the user from ending up in a scenario where they've autosaved the file
// to an internal storage location, thus marking it as not dirty, but no longer able to
// access the file if the accidentally go to create a new file without properly saving
// the current one
fileHelper.save(file, _state.value.markdown.text)
preferenceHelper[Preference.AUTOSAVE_URI] = file
}
}
}
fun reset(untitledFileName: String, force: Boolean = false) {
Timber.i("Resetting view model to default state")
if (!force && _state.value.dirty) {
updateState {
copy(alert = AlertDialogModel(
text = ParameterizedText(R.string.prompt_save_changes),
confirmButton = AlertDialogModel.ButtonModel(
text = ParameterizedText(R.string.yes),
onClick = {
_state.value = _state.value.copy(
saveCallback = {
reset(untitledFileName, false)
}
)
}
),
dismissButton = AlertDialogModel.ButtonModel(
text = ParameterizedText(R.string.no),
onClick = {
reset(untitledFileName, true)
}
)
))
}
return
}
updateState {
EditorState(
fileName = untitledFileName,
lockSwiping = preferenceHelper[Preference.LOCK_SWIPING] as Boolean
)
}
Timber.i("Removing autosave uri from shared prefs")
preferenceHelper[Preference.AUTOSAVE_URI] = null
}
fun setLockSwiping(enabled: Boolean) {
preferenceHelper[Preference.LOCK_SWIPING] = enabled
}
private fun updateState(block: EditorState.() -> EditorState) {
_state.value = _state.value.block()
}
companion object {
fun factory(
fileHelper: FileHelper,
preferenceHelper: PreferenceHelper
): ViewModelProvider.Factory = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(
modelClass: Class<T>,
extras: CreationExtras
): T {
return MarkdownViewModel(fileHelper, preferenceHelper) as T
}
}
}
}
data class AlertDialogModel(
val text: ParameterizedText,
val confirmButton: ButtonModel,
val dismissButton: ButtonModel? = null
) {
data class ButtonModel(val text: ParameterizedText, val onClick: () -> Unit)
}
data class ParameterizedText(@StringRes val text: Int, val params: Array<Any> = arrayOf()) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ParameterizedText
if (text != other.text) return false
if (!params.contentEquals(other.params)) return false
return true
}
override fun hashCode(): Int {
var result = text
result = 31 * result + params.contentHashCode()
return result
}
}
private fun String.annotate(enableReadability: Boolean): AnnotatedString {
if (!enableReadability) return AnnotatedString(this)
val readability = Readability(this)
val annotated = AnnotatedString.Builder(this)
for (sentence in readability.sentences()) {
var color = Color.Transparent
if (sentence.syllableCount() > 25) color = Color(229, 232, 42, 100)
if (sentence.syllableCount() > 35) color = Color(193, 66, 66, 100)
annotated.addStyle(SpanStyle(background = color), sentence.start(), sentence.end())
}
return annotated.toAnnotatedString()
}

View file

@ -8,7 +8,7 @@ class Readability(private val content: String) {
val list = ArrayList<Sentence>()
var startOfSentance = 0
var lineBuilder = StringBuilder()
for (i in content.indices) {
for (i in 0 until content.length) {
val c = content[i] + ""
if (DELIMS.contains(c)) {
list.add(Sentence(content, startOfSentance, i))

View file

@ -1,369 +0,0 @@
package com.wbrawner.simplemarkdown.ui
import android.content.Intent
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Snackbar
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat.startActivity
import androidx.navigation.NavController
import com.wbrawner.simplemarkdown.AlertDialogModel
import com.wbrawner.simplemarkdown.EditorState
import com.wbrawner.simplemarkdown.MarkdownViewModel
import com.wbrawner.simplemarkdown.ParameterizedText
import com.wbrawner.simplemarkdown.R
import com.wbrawner.simplemarkdown.Route
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.net.URI
import kotlin.reflect.KProperty1
@Composable
fun MainScreen(
navController: NavController,
viewModel: MarkdownViewModel,
enableWideLayout: Boolean,
enableAutosave: Boolean,
) {
val coroutineScope = rememberCoroutineScope()
val fileName by viewModel.collectAsState(EditorState::fileName, "")
val markdown by viewModel.collectAsState(EditorState::markdown, TextFieldValue(""))
val dirty by viewModel.collectAsState(EditorState::dirty, false)
val alert by viewModel.collectAsState(EditorState::alert, null)
val saveCallback by viewModel.collectAsState(EditorState::saveCallback, null)
val lockSwiping by viewModel.collectAsState(EditorState::lockSwiping, false)
LaunchedEffect(enableAutosave) {
if (!enableAutosave) return@LaunchedEffect
while (isActive) {
delay(500)
viewModel.autosave()
}
}
val toast by viewModel.collectAsState(EditorState::toast, null)
MainScreen(
dirty = dirty,
fileName = fileName,
markdown = markdown,
setMarkdown = viewModel::updateMarkdown,
lockSwiping = lockSwiping,
toggleLockSwiping = viewModel::setLockSwiping,
message = toast?.stringRes(),
dismissMessage = viewModel::dismissToast,
alert = alert,
dismissAlert = viewModel::dismissAlert,
navigate = {
navController.navigate(it.path)
},
navigateBack = { navController.popBackStack() },
loadFile = {
coroutineScope.launch {
viewModel.load(it.toString())
}
},
saveFile = {
coroutineScope.launch {
viewModel.save(it)
}
},
saveCallback = saveCallback,
reset = {
viewModel.reset("Untitled.md")
},
enableWideLayout = enableWideLayout,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun MainScreen(
fileName: String = "Untitled.md",
dirty: Boolean = false,
markdown: TextFieldValue = TextFieldValue(""),
setMarkdown: (TextFieldValue) -> Unit = {},
lockSwiping: Boolean,
toggleLockSwiping: (Boolean) -> Unit,
message: String? = null,
dismissMessage: () -> Unit = {},
alert: AlertDialogModel? = null,
dismissAlert: () -> Unit = {},
navigate: (Route) -> Unit = {},
navigateBack: () -> Unit = {},
loadFile: (Uri?) -> Unit = {},
saveFile: (URI?) -> Unit = {},
saveCallback: (() -> Unit)? = null,
reset: () -> Unit = {},
enableWideLayout: Boolean = false,
) {
val openFileLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) {
loadFile(it)
}
val saveFileLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/*")) {
it?.let { uri -> saveFile(URI.create(uri.toString())) }
}
saveCallback?.let { callback ->
val launcher =
rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/*")) {
it?.let { uri -> saveFile(URI.create(uri.toString())) }
callback()
}
LaunchedEffect(callback) {
launcher.launch(fileName)
}
}
val snackBarState = remember { SnackbarHostState() }
LaunchedEffect(message) {
message?.let {
snackBarState.showSnackbar(it)
dismissMessage()
}
}
alert?.let {
AlertDialog(
onDismissRequest = dismissAlert,
confirmButton = {
TextButton(onClick = it.confirmButton.onClick) {
Text(stringResource(it.confirmButton.text.text))
}
},
dismissButton = {
it.dismissButton?.let { dismissButton ->
TextButton(onClick = dismissButton.onClick) {
Text(dismissButton.text.stringRes())
}
}
},
text = { Text(it.text.stringRes()) }
)
}
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior()
MarkdownNavigationDrawer(navigate) { drawerState ->
Scaffold(
topBar = {
val context = LocalContext.current
MarkdownTopAppBar(
title = if (dirty) "$fileName*" else fileName,
backAsUp = false,
goBack = navigateBack,
drawerState = drawerState,
actions = {
IconButton(onClick = {
val shareIntent = Intent(Intent.ACTION_SEND)
shareIntent.putExtra(Intent.EXTRA_TEXT, markdown.text)
shareIntent.type = "text/plain"
startActivity(
context, Intent.createChooser(
shareIntent, context.getString(R.string.share_file)
), null
)
}) {
Icon(imageVector = Icons.Default.Share, contentDescription = stringResource(R.string.action_share))
}
Box {
var menuExpanded by remember { mutableStateOf(false) }
IconButton(onClick = { menuExpanded = true }) {
Icon(imageVector = Icons.Default.MoreVert, stringResource(R.string.action_editor_actions))
}
DropdownMenu(expanded = menuExpanded,
onDismissRequest = { menuExpanded = false }) {
DropdownMenuItem(text = { Text(stringResource(R.string.action_new)) }, onClick = {
menuExpanded = false
reset()
})
DropdownMenuItem(text = { Text(stringResource(R.string.action_open)) }, onClick = {
menuExpanded = false
openFileLauncher.launch(arrayOf("text/*"))
})
DropdownMenuItem(text = { Text(stringResource(R.string.action_save)) }, onClick = {
menuExpanded = false
saveFile(null)
})
DropdownMenuItem(text = { Text(stringResource(R.string.action_save_as )) },
onClick = {
menuExpanded = false
saveFileLauncher.launch(fileName)
})
if (!enableWideLayout) {
DropdownMenuItem(text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.action_lock_swipe))
Checkbox(
checked = lockSwiping,
onCheckedChange = toggleLockSwiping
)
}
}, onClick = {
toggleLockSwiping(!lockSwiping)
menuExpanded = false
})
}
}
}
},
scrollBehavior = scrollBehavior
)
},
snackbarHost = {
SnackbarHost(
modifier = Modifier.imePadding(),
hostState = snackBarState
) {
Snackbar(it)
}
}
) { paddingValues ->
if (enableWideLayout) {
Row(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
MarkdownTextField(
modifier = Modifier
.fillMaxHeight()
.weight(1f),
markdown = markdown,
setMarkdown = setMarkdown,
)
Spacer(
modifier = Modifier
.fillMaxHeight()
.width(1.dp)
.background(color = MaterialTheme.colorScheme.primary)
)
MarkdownText(
modifier = Modifier
.fillMaxHeight()
.weight(1f),
markdown = markdown.text
)
}
} else {
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
TabbedMarkdownEditor(
markdown = markdown,
setMarkdown = setMarkdown,
lockSwiping = lockSwiping,
scrollBehavior = scrollBehavior
)
}
}
}
}
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun TabbedMarkdownEditor(
markdown: TextFieldValue,
setMarkdown: (TextFieldValue) -> Unit,
lockSwiping: Boolean,
scrollBehavior: TopAppBarScrollBehavior
) {
val coroutineScope = rememberCoroutineScope()
val pagerState = rememberPagerState { 2 }
TabRow(selectedTabIndex = pagerState.currentPage) {
Tab(text = { Text(stringResource(R.string.action_edit)) },
selected = pagerState.currentPage == 0,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } })
Tab(text = { Text(stringResource(R.string.action_preview)) },
selected = pagerState.currentPage == 1,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } })
}
HorizontalPager(
modifier = Modifier.fillMaxSize(1f), state = pagerState,
beyondViewportPageCount = 1,
userScrollEnabled = !lockSwiping
) { page ->
val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(page) {
when (page) {
0 -> keyboardController?.show()
else -> keyboardController?.hide()
}
}
if (page == 0) {
MarkdownTextField(
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
markdown = markdown,
setMarkdown = setMarkdown,
)
} else {
MarkdownText(
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
markdown.text
)
}
}
}
@Composable
fun <P> MarkdownViewModel.collectAsState(prop: KProperty1<EditorState, P>, initial: P): State<P> =
remember(prop) { state.map { prop.get(it) }.distinctUntilChanged() }.collectAsState(initial)
@Composable
fun ParameterizedText.stringRes() = stringResource(text, *params)

View file

@ -1,43 +0,0 @@
package com.wbrawner.simplemarkdown.ui
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.NavController
import com.wbrawner.simplemarkdown.utility.readAssetToString
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MarkdownInfoScreen(
title: String,
file: String,
navController: NavController,
) {
Scaffold(
topBar = {
MarkdownTopAppBar(
title = title,
goBack = { navController.popBackStack() },
)
}
) { paddingValues ->
val context = LocalContext.current
val (markdown, setMarkdown) = remember { mutableStateOf("") }
LaunchedEffect(file) {
setMarkdown(context.assets.readAssetToString(file))
}
MarkdownText(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
markdown = markdown
)
}
}

View file

@ -1,80 +0,0 @@
package com.wbrawner.simplemarkdown.ui
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.material3.DismissibleDrawerSheet
import androidx.compose.material3.DismissibleNavigationDrawer
import androidx.compose.material3.DrawerState
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.Text
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.wbrawner.simplemarkdown.R
import com.wbrawner.simplemarkdown.Route
import kotlinx.coroutines.launch
@Composable
fun MarkdownNavigationDrawer(
navigate: (Route) -> Unit, content: @Composable (drawerState: DrawerState) -> Unit
) {
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val coroutineScope = rememberCoroutineScope()
BackHandler(enabled = drawerState.isOpen) {
coroutineScope.launch {
drawerState.close()
}
}
DismissibleNavigationDrawer(
gesturesEnabled = drawerState.isOpen,
drawerState = drawerState,
drawerContent = {
DismissibleDrawerSheet {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
modifier = Modifier.size(96.dp),
painter = painterResource(R.drawable.ic_launcher_foreground),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface
)
Text(
text = stringResource(R.string.app_name),
style = MaterialTheme.typography.titleLarge
)
}
Route.entries.forEach { route ->
if (route == Route.EDITOR) {
return@forEach
}
NavigationDrawerItem(
icon = {
Icon(imageVector = route.icon, contentDescription = null)
},
label = { Text(stringResource(route.title)) },
selected = false,
onClick = {
navigate(route)
coroutineScope.launch {
drawerState.close()
}
}
)
}
}
}) {
content(drawerState)
}
}

View file

@ -1,125 +0,0 @@
package com.wbrawner.simplemarkdown.ui
import android.annotation.SuppressLint
import android.graphics.Color.TRANSPARENT
import android.view.ViewGroup
import android.webkit.WebView
import android.widget.FrameLayout
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.viewinterop.AndroidView
import com.wbrawner.simplemarkdown.BuildConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.commonmark.ext.autolink.AutolinkExtension
import org.commonmark.ext.front.matter.YamlFrontMatterExtension
import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension
import org.commonmark.ext.gfm.tables.TablesExtension
import org.commonmark.ext.heading.anchor.HeadingAnchorExtension
import org.commonmark.ext.image.attributes.ImageAttributesExtension
import org.commonmark.ext.task.list.items.TaskListItemsExtension
import org.commonmark.parser.Parser
import org.commonmark.renderer.html.HtmlRenderer
private val markdownExtensions = listOf(
AutolinkExtension.create(),
StrikethroughExtension.create(),
TablesExtension.create(),
HeadingAnchorExtension.create(),
YamlFrontMatterExtension.create(),
ImageAttributesExtension.create(),
TaskListItemsExtension.create(),
)
private val markdownParser = Parser.builder()
.extensions(markdownExtensions)
.build()
private val renderer = HtmlRenderer.builder()
.extensions(markdownExtensions)
.build()
@Composable
fun MarkdownText(modifier: Modifier = Modifier, markdown: String) {
val (html, setHtml) = remember { mutableStateOf("") }
LaunchedEffect(markdown) {
withContext(Dispatchers.IO) {
val parsedHtml = renderer.render(
markdownParser.parse(markdown)
)
setHtml(parsedHtml)
}
}
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.Top
) {
HtmlText(html = html)
}
}
@SuppressLint("SetJavaScriptEnabled")
@OptIn(ExperimentalStdlibApi::class)
@Composable
fun HtmlText(html: String, modifier: Modifier = Modifier) {
val materialColors = MaterialTheme.colorScheme
val style = remember(isSystemInDarkTheme()) {
"""body {
| background: #${materialColors.surface.toArgb().toHexString().substring(2)};
| color: #${materialColors.onSurface.toArgb().toHexString().substring(2)};
|}
|a {
| color: #${materialColors.secondary.toArgb().toHexString().substring(2)};
|}
|pre {
| background: #${materialColors.surfaceVariant.toArgb().toHexString().substring(2)};
| color: #${materialColors.onSurfaceVariant.toArgb().toHexString().substring(2)};
|}""".trimMargin().wrapTag("style")
}
AndroidView(
modifier = modifier,
factory = { context ->
FrameLayout(context).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
addView(
WebView(context).apply {
tag = WEBVIEW_TAG
WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG)
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
setBackgroundColor(TRANSPARENT)
isNestedScrollingEnabled = false
settings.javaScriptEnabled = true
loadDataWithBaseURL(null, style + html, "text/html", "UTF-8", null)
}
)
}
},
update = { frameLayout ->
frameLayout.findViewWithTag<WebView>(WEBVIEW_TAG)
.loadDataWithBaseURL(null, style + html, "text/html", "UTF-8", null)
}
)
}
private const val WEBVIEW_TAG = "com.wbrawner.simplemarkdown.MarkdownText#WebView"
private fun String.wrapTag(tag: String) = "<$tag>$this</$tag>"

View file

@ -1,82 +0,0 @@
package com.wbrawner.simplemarkdown.ui
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.selection.LocalTextSelectionColors
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import com.wbrawner.simplemarkdown.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MarkdownTextField(
modifier: Modifier = Modifier,
markdown: TextFieldValue,
setMarkdown: (TextFieldValue) -> Unit,
) {
val colors = TextFieldDefaults.colors(
focusedContainerColor = MaterialTheme.colorScheme.surface,
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
disabledIndicatorColor = Color.Transparent,
errorIndicatorColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent
)
val interactionSource = remember { MutableInteractionSource() }
val textStyle = TextStyle.Default.copy(
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface
)
CompositionLocalProvider(LocalTextSelectionColors provides colors.textSelectionColors) {
BasicTextField(
value = markdown,
modifier = modifier.imePadding(),
onValueChange = setMarkdown,
enabled = true,
readOnly = false,
textStyle = textStyle,
cursorBrush = SolidColor(colors.cursorColor),
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences),
keyboardActions = KeyboardActions.Default,
interactionSource = interactionSource,
singleLine = false,
maxLines = Int.MAX_VALUE,
minLines = 1,
decorationBox = @Composable { innerTextField ->
// places leading icon, text field with label and placeholder, trailing icon
TextFieldDefaults.DecorationBox(
value = markdown.text,
visualTransformation = VisualTransformation.None,
innerTextField = innerTextField,
placeholder = {
Text(stringResource(R.string.markdown_here))
},
singleLine = false,
enabled = true,
interactionSource = interactionSource,
colors = colors,
contentPadding = PaddingValues(8.dp)
)
},
)
}
}

View file

@ -1,66 +0,0 @@
package com.wbrawner.simplemarkdown.ui
import androidx.compose.foundation.layout.RowScope
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.DrawerState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults.topAppBarColors
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import com.wbrawner.simplemarkdown.R
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MarkdownTopAppBar(
title: String,
goBack: () -> Unit,
backAsUp: Boolean = true,
drawerState: DrawerState? = null,
actions: (@Composable RowScope.() -> Unit)? = null,
scrollBehavior: TopAppBarScrollBehavior? = null,
) {
val coroutineScope = rememberCoroutineScope()
val context = LocalContext.current
TopAppBar(
title = {
Text(text = title, maxLines = 1, overflow = TextOverflow.Ellipsis)
},
colors = topAppBarColors(scrolledContainerColor = MaterialTheme.colorScheme.surface),
navigationIcon = {
val (icon, contentDescription, onClick) = remember {
if (backAsUp) {
Triple(Icons.AutoMirrored.Filled.ArrowBack, context.getString(R.string.action_back), goBack)
} else {
Triple(
Icons.Default.Menu, context.getString(R.string.action_menu)
) {
coroutineScope.launch {
if (drawerState?.isOpen == true) {
drawerState.close()
} else {
drawerState?.open()
}
}
}
}
}
IconButton(onClick = { onClick() }) {
Icon(imageVector = icon, contentDescription = contentDescription)
}
},
actions = actions ?: {},
scrollBehavior = scrollBehavior
)
}

View file

@ -1,258 +0,0 @@
package com.wbrawner.simplemarkdown.ui
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.wbrawner.simplemarkdown.BuildConfig
import com.wbrawner.simplemarkdown.R
import com.wbrawner.simplemarkdown.ui.theme.SimpleMarkdownTheme
import com.wbrawner.simplemarkdown.utility.Preference
import com.wbrawner.simplemarkdown.utility.PreferenceHelper
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(navController: NavController, preferenceHelper: PreferenceHelper) {
Scaffold(topBar = {
MarkdownTopAppBar(title = "Settings", goBack = { navController.popBackStack() })
}) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
) {
BooleanPreference(
title = stringResource(R.string.pref_title_autosave),
enabledDescription = stringResource(R.string.pref_autosave_on),
disabledDescription = stringResource(R.string.pref_autosave_off),
preference = Preference.AUTOSAVE_ENABLED,
preferenceHelper = preferenceHelper
)
ListPreference(
title = stringResource(R.string.title_dark_mode),
options = stringArrayResource(R.array.pref_values_dark_mode),
preference = Preference.DARK_MODE,
preferenceHelper = preferenceHelper
)
BooleanPreference(
title = stringResource(R.string.pref_title_error_reports),
enabledDescription = stringResource(R.string.pref_error_reports_on),
disabledDescription = stringResource(R.string.pref_error_reports_off),
preference = Preference.ERROR_REPORTS_ENABLED,
preferenceHelper = preferenceHelper
)
BooleanPreference(
title = stringResource(R.string.pref_title_analytics),
enabledDescription = stringResource(R.string.pref_analytics_on),
disabledDescription = stringResource(R.string.pref_analytics_off),
preference = Preference.ANALYTICS_ENABLED,
preferenceHelper = preferenceHelper
)
BooleanPreference(
title = stringResource(R.string.pref_title_readability),
enabledDescription = stringResource(R.string.pref_readability_on),
disabledDescription = stringResource(R.string.pref_readability_off),
preference = Preference.READABILITY_ENABLED,
preferenceHelper = preferenceHelper
)
if (BuildConfig.DEBUG) {
Row(modifier = Modifier
.fillMaxWidth()
.clickable {
error("Forced crash")
}
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically) {
Column(verticalArrangement = Arrangement.Center) {
Text(text = stringResource(R.string.action_force_crash), style = MaterialTheme.typography.bodyLarge)
Text(
text = stringResource(R.string.description_force_crash),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
}
@Composable
fun BooleanPreference(
title: String,
enabledDescription: String,
disabledDescription: String,
preference: Preference,
preferenceHelper: PreferenceHelper
) {
var enabled by remember {
mutableStateOf(preferenceHelper[preference] as Boolean)
}
BooleanPreference(title = title,
enabledDescription = enabledDescription,
disabledDescription = disabledDescription,
enabled = enabled,
setEnabled = {
enabled = it
preferenceHelper[preference] = it
})
}
@Composable
fun BooleanPreference(
title: String,
enabledDescription: String,
disabledDescription: String,
enabled: Boolean,
setEnabled: (Boolean) -> Unit
) {
Row(modifier = Modifier
.fillMaxWidth()
.clickable {
setEnabled(!enabled)
}
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically) {
Column(verticalArrangement = Arrangement.Center) {
Text(text = title, style = MaterialTheme.typography.bodyLarge)
Text(
text = if (enabled) enabledDescription else disabledDescription,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(checked = enabled, onCheckedChange = setEnabled)
}
}
@Composable
fun ListPreference(
title: String,
options: Array<String>,
preference: Preference,
preferenceHelper: PreferenceHelper
) {
var selected by remember {
mutableStateOf(preferenceHelper[preference] as String)
}
ListPreference(title = title, options = options, selected = selected, setSelected = {
selected = it
preferenceHelper[preference] = it
})
}
@Composable
fun ListPreference(
title: String, options: Array<String>, selected: String, setSelected: (String) -> Unit
) {
var dialogShowing by remember { mutableStateOf(false) }
Column(modifier = Modifier
.fillMaxWidth()
.clickable {
dialogShowing = true
}
.padding(16.dp), verticalArrangement = Arrangement.Center) {
Text(text = title, style = MaterialTheme.typography.bodyLarge)
Text(
text = selected,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (dialogShowing) {
AlertDialog(
title = {
Text(title)
},
onDismissRequest = { dialogShowing = false },
confirmButton = {
TextButton(onClick = { dialogShowing = false }) {
Text("Cancel")
}
},
text = {
Column {
options.forEach { option ->
val onClick = {
setSelected(option)
dialogShowing = false
}
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(selected = option == selected, onClick = onClick)
Text(option)
}
}
}
}
)
}
}
@Preview
@Preview(uiMode = UI_MODE_NIGHT_YES)
@Composable
fun BooleanPreference_Preview() {
val (enabled, setEnabled) = remember { mutableStateOf(true) }
SimpleMarkdownTheme {
Surface {
BooleanPreference(
"Autosave",
"Files will be saved automatically",
"Files will not be saved automatically",
enabled,
setEnabled
)
}
}
}
@Preview
@Preview(uiMode = UI_MODE_NIGHT_YES)
@Composable
fun ListPreference_Preview() {
val (selected, setSelected) = remember { mutableStateOf("Auto") }
SimpleMarkdownTheme {
Surface {
ListPreference(
"Dark mode", arrayOf("Light", "Dark", "Auto"), selected, setSelected
)
}
}
}

View file

@ -1,107 +0,0 @@
package com.wbrawner.simplemarkdown.ui
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import androidx.browser.customtabs.CustomTabsIntent
import androidx.browser.customtabs.CustomTabsIntent.SHARE_STATE_ON
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat.startActivity
import androidx.navigation.NavController
import com.wbrawner.simplemarkdown.R
import com.wbrawner.simplemarkdown.utility.SupportLinks
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SupportScreen(navController: NavController) {
Scaffold(topBar = {
MarkdownTopAppBar(title = stringResource(R.string.support_title), goBack = { navController.popBackStack() })
}) { paddingValues ->
val context = LocalContext.current
Column(
modifier = Modifier
.padding(paddingValues)
.padding(16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
modifier = Modifier.size(100.dp),
imageVector = Icons.Default.Favorite,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Text(stringResource(R.string.support_info), textAlign = TextAlign.Center)
Spacer(modifier = Modifier.height(8.dp))
Button(
modifier = Modifier.fillMaxWidth(),
onClick = {
CustomTabsIntent.Builder()
.setShareState(SHARE_STATE_ON)
.build()
.launchUrl(context, Uri.parse("https://github.com/wbrawner/SimpleMarkdown"))
},
colors = ButtonDefaults.buttonColors(
containerColor = Color(context.getColor(R.color.colorBackgroundGitHub)),
contentColor = Color.White
)
) {
Text(stringResource(R.string.action_view_github))
}
Button(
modifier = Modifier.fillMaxWidth(),
onClick = {
val playStoreIntent = Intent(Intent.ACTION_VIEW)
.apply {
data = Uri.parse("market://details?id=${context.packageName}")
addFlags(
Intent.FLAG_ACTIVITY_NO_HISTORY or
Intent.FLAG_ACTIVITY_NEW_DOCUMENT or
Intent.FLAG_ACTIVITY_MULTIPLE_TASK
)
}
try {
startActivity(context, playStoreIntent, null)
} catch (ignored: ActivityNotFoundException) {
playStoreIntent.data =
Uri.parse("https://play.google.com/store/apps/details?id=${context.packageName}")
startActivity(context, playStoreIntent, null)
}
},
colors = ButtonDefaults.buttonColors(
containerColor = Color(context.getColor(R.color.colorBackgroundPlayStore)),
contentColor = Color.White
)
) {
Text(stringResource(R.string.action_rate))
}
SupportLinks()
}
}
}

View file

@ -1,67 +0,0 @@
package com.wbrawner.simplemarkdown.ui.theme
import androidx.compose.ui.graphics.Color
val md_theme_light_primary = Color(0xFFBA1A20)
val md_theme_light_onPrimary = Color(0xFFFFFFFF)
val md_theme_light_primaryContainer = Color(0xFFFFDAD6)
val md_theme_light_onPrimaryContainer = Color(0xFF410003)
val md_theme_light_secondary = Color(0xFF775653)
val md_theme_light_onSecondary = Color(0xFFFFFFFF)
val md_theme_light_secondaryContainer = Color(0xFFFFDAD6)
val md_theme_light_onSecondaryContainer = Color(0xFF2C1513)
val md_theme_light_tertiary = Color(0xFF725B2E)
val md_theme_light_onTertiary = Color(0xFFFFFFFF)
val md_theme_light_tertiaryContainer = Color(0xFFFEDEA6)
val md_theme_light_onTertiaryContainer = Color(0xFF261900)
val md_theme_light_error = Color(0xFFBA1A1A)
val md_theme_light_errorContainer = Color(0xFFFFDAD6)
val md_theme_light_onError = Color(0xFFFFFFFF)
val md_theme_light_onErrorContainer = Color(0xFF410002)
val md_theme_light_background = Color(0xFFFFFBFF)
val md_theme_light_onBackground = Color(0xFF201A19)
val md_theme_light_surface = Color(0xFFFFFBFF)
val md_theme_light_onSurface = Color(0xFF201A19)
val md_theme_light_surfaceVariant = Color(0xFFF5DDDB)
val md_theme_light_onSurfaceVariant = Color(0xFF534342)
val md_theme_light_outline = Color(0xFF857371)
val md_theme_light_inverseOnSurface = Color(0xFFFBEEEC)
val md_theme_light_inverseSurface = Color(0xFF362F2E)
val md_theme_light_inversePrimary = Color(0xFFFFB3AC)
val md_theme_light_shadow = Color(0xFF000000)
val md_theme_light_surfaceTint = Color(0xFFBA1A20)
val md_theme_light_outlineVariant = Color(0xFFD8C2BF)
val md_theme_light_scrim = Color(0xFF000000)
val md_theme_dark_primary = Color(0xFFFFB3AC)
val md_theme_dark_onPrimary = Color(0xFF680008)
val md_theme_dark_primaryContainer = Color(0xFF930010)
val md_theme_dark_onPrimaryContainer = Color(0xFFFFDAD6)
val md_theme_dark_secondary = Color(0xFFE7BDB8)
val md_theme_dark_onSecondary = Color(0xFF442927)
val md_theme_dark_secondaryContainer = Color(0xFF5D3F3C)
val md_theme_dark_onSecondaryContainer = Color(0xFFFFDAD6)
val md_theme_dark_tertiary = Color(0xFFE1C38C)
val md_theme_dark_onTertiary = Color(0xFF3F2D04)
val md_theme_dark_tertiaryContainer = Color(0xFF584419)
val md_theme_dark_onTertiaryContainer = Color(0xFFFEDEA6)
val md_theme_dark_error = Color(0xFFFFB4AB)
val md_theme_dark_errorContainer = Color(0xFF93000A)
val md_theme_dark_onError = Color(0xFF690005)
val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6)
val md_theme_dark_background = Color(0xFF201A19)
val md_theme_dark_onBackground = Color(0xFFEDE0DE)
val md_theme_dark_surface = Color(0xFF201A19)
val md_theme_dark_onSurface = Color(0xFFEDE0DE)
val md_theme_dark_surfaceVariant = Color(0xFF534342)
val md_theme_dark_onSurfaceVariant = Color(0xFFD8C2BF)
val md_theme_dark_outline = Color(0xFFA08C8A)
val md_theme_dark_inverseOnSurface = Color(0xFF201A19)
val md_theme_dark_inverseSurface = Color(0xFFEDE0DE)
val md_theme_dark_inversePrimary = Color(0xFFBA1A20)
val md_theme_dark_shadow = Color(0xFF000000)
val md_theme_dark_surfaceTint = Color(0xFFFFB3AC)
val md_theme_dark_outlineVariant = Color(0xFF534342)
val md_theme_dark_scrim = Color(0xFF000000)
val seed = Color(0xFFD32F2F)

View file

@ -1,90 +0,0 @@
package com.wbrawner.simplemarkdown.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
private val LightColors = lightColorScheme(
primary = md_theme_light_primary,
onPrimary = md_theme_light_onPrimary,
primaryContainer = md_theme_light_primaryContainer,
onPrimaryContainer = md_theme_light_onPrimaryContainer,
secondary = md_theme_light_secondary,
onSecondary = md_theme_light_onSecondary,
secondaryContainer = md_theme_light_secondaryContainer,
onSecondaryContainer = md_theme_light_onSecondaryContainer,
tertiary = md_theme_light_tertiary,
onTertiary = md_theme_light_onTertiary,
tertiaryContainer = md_theme_light_tertiaryContainer,
onTertiaryContainer = md_theme_light_onTertiaryContainer,
error = md_theme_light_error,
errorContainer = md_theme_light_errorContainer,
onError = md_theme_light_onError,
onErrorContainer = md_theme_light_onErrorContainer,
background = md_theme_light_background,
onBackground = md_theme_light_onBackground,
surface = md_theme_light_surface,
onSurface = md_theme_light_onSurface,
surfaceVariant = md_theme_light_surfaceVariant,
onSurfaceVariant = md_theme_light_onSurfaceVariant,
outline = md_theme_light_outline,
inverseOnSurface = md_theme_light_inverseOnSurface,
inverseSurface = md_theme_light_inverseSurface,
inversePrimary = md_theme_light_inversePrimary,
surfaceTint = md_theme_light_surfaceTint,
outlineVariant = md_theme_light_outlineVariant,
scrim = md_theme_light_scrim,
)
private val DarkColors = darkColorScheme(
primary = md_theme_dark_primary,
onPrimary = md_theme_dark_onPrimary,
primaryContainer = md_theme_dark_primaryContainer,
onPrimaryContainer = md_theme_dark_onPrimaryContainer,
secondary = md_theme_dark_secondary,
onSecondary = md_theme_dark_onSecondary,
secondaryContainer = md_theme_dark_secondaryContainer,
onSecondaryContainer = md_theme_dark_onSecondaryContainer,
tertiary = md_theme_dark_tertiary,
onTertiary = md_theme_dark_onTertiary,
tertiaryContainer = md_theme_dark_tertiaryContainer,
onTertiaryContainer = md_theme_dark_onTertiaryContainer,
error = md_theme_dark_error,
errorContainer = md_theme_dark_errorContainer,
onError = md_theme_dark_onError,
onErrorContainer = md_theme_dark_onErrorContainer,
background = md_theme_dark_background,
onBackground = md_theme_dark_onBackground,
surface = md_theme_dark_surface,
onSurface = md_theme_dark_onSurface,
surfaceVariant = md_theme_dark_surfaceVariant,
onSurfaceVariant = md_theme_dark_onSurfaceVariant,
outline = md_theme_dark_outline,
inverseOnSurface = md_theme_dark_inverseOnSurface,
inverseSurface = md_theme_dark_inverseSurface,
inversePrimary = md_theme_dark_inversePrimary,
surfaceTint = md_theme_dark_surfaceTint,
outlineVariant = md_theme_dark_outlineVariant,
scrim = md_theme_dark_scrim,
)
@Composable
fun SimpleMarkdownTheme(
useDarkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colors = if (!useDarkTheme) {
LightColors
} else {
DarkColors
}
MaterialTheme(
colorScheme = colors,
content = content
)
}

View file

@ -0,0 +1,43 @@
package com.wbrawner.simplemarkdown.utility
import android.content.Context
import android.util.Log
import com.wbrawner.simplemarkdown.BuildConfig
import io.sentry.android.core.SentryAndroid
import io.sentry.core.Sentry
import java.util.concurrent.atomic.AtomicBoolean
interface ErrorHandler {
fun init(context: Context, enable: Boolean)
fun enable(enable: Boolean)
fun reportException(t: Throwable, message: String? = null)
}
class SentryErrorHandler : ErrorHandler {
private lateinit var enabled: AtomicBoolean
override fun init(context: Context, enable: Boolean) {
enabled = AtomicBoolean(enable)
SentryAndroid.init(context) { options ->
options.setBeforeSend { event, _ ->
if (enabled.get()) {
event
} else {
null
}
}
}
}
override fun enable(enable: Boolean) {
enabled.set(enable)
}
override fun reportException(t: Throwable, message: String?) {
@Suppress("ConstantConditionIf")
if (BuildConfig.DEBUG) {
Log.e("SentryErrorHandler", "Caught exception: $message", t)
}
Sentry.captureException(t)
}
}

View file

@ -1,21 +1,42 @@
package com.wbrawner.simplemarkdown.utility
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.content.res.AssetManager
import android.net.Uri
import android.provider.OpenableColumns
import android.view.View
import android.view.inputmethod.InputMethodManager
import com.commonsware.cwac.anddown.AndDown
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.Reader
suspend fun AssetManager.readAssetToString(asset: String): String {
fun View.showKeyboard() {
(context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager)
.toggleSoftInput(InputMethodManager.SHOW_FORCED, 0)
requestFocus()
}
fun View.hideKeyboard() =
(context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager)
.hideSoftInputFromWindow(windowToken, 0)
suspend fun AssetManager.readAssetToString(asset: String): String? {
return withContext(Dispatchers.IO) {
open(asset).reader().use(Reader::readText)
}
}
const val HOEDOWN_FLAGS = AndDown.HOEDOWN_EXT_STRIKETHROUGH or AndDown.HOEDOWN_EXT_TABLES or
AndDown.HOEDOWN_EXT_UNDERLINE or AndDown.HOEDOWN_EXT_SUPERSCRIPT or
AndDown.HOEDOWN_EXT_FENCED_CODE
suspend fun String.toHtml(): String {
return withContext(Dispatchers.IO) {
AndDown().markdownToHtml(this@toHtml, HOEDOWN_FLAGS, 0)
}
}
suspend fun Uri.getName(context: Context): String {
var fileName: String? = null
try {
@ -41,11 +62,3 @@ suspend fun Uri.getName(context: Context): String {
}
return fileName ?: "Untitled.md"
}
@Suppress("RecursivePropertyAccessor")
val Context.activity: Activity?
get() = when (this) {
is Activity -> this
is ContextWrapper -> baseContext.activity
else -> null
}

View file

@ -1,73 +0,0 @@
package com.wbrawner.simplemarkdown.utility
import android.content.Context
import android.content.Intent
import android.os.Environment
import androidx.core.net.toUri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.File
import java.io.FileInputStream
import java.io.IOException
import java.io.Reader
import java.net.URI
interface FileHelper {
val defaultDirectory: File
/**
* Opens a file at the given path
* @param path The path of the file to open
* @return A [Pair] of the file name to the file's contents
*/
suspend fun open(source: URI): Pair<String, String>?
/**
* Saves the given content to the given path
* @param path
* @param content
* @return The name of the saved file
*/
suspend fun save(destination: URI, content: String): String
}
class AndroidFileHelper(private val context: Context) : FileHelper {
override val defaultDirectory: File by lazy {
context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS)
?: context.filesDir
}
override suspend fun open(source: URI): Pair<String, String>? = withContext(Dispatchers.IO) {
val uri = source.toString().toUri()
try {
context.contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
} catch (e: SecurityException) {
// We weren't granted the persistent read/write permission for this file.
// TODO: Return whether or not we got the persistent permission in order to determine
// whether or not we should show this file in the recent files section
}
context.contentResolver.openFileDescriptor(uri, "r")
?.use {
uri.getName(context) to FileInputStream(it.fileDescriptor).reader()
.use(Reader::readText)
}
}
override suspend fun save(destination: URI, content: String): String = withContext(Dispatchers.IO) {
val uri = destination.toString().toUri()
context.contentResolver.openOutputStream(uri, "rwt")
?.writer()
?.use {
it.write(content)
}
?: run {
Timber.w("Open output stream returned null for uri: $uri")
throw IOException("Failed to save to $destination")
}
return@withContext uri.getName(context)
}
}

View file

@ -1,80 +0,0 @@
package com.wbrawner.simplemarkdown.utility
import android.util.Log
import com.wbrawner.simplemarkdown.utility.PersistentTree.Companion.create
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.PrintStream
import java.text.SimpleDateFormat
import java.util.*
/**
* A [Timber.Tree] implementation that persists all logs to disk for retrieval later. Create
* instances via [create] instead of calling the constructor directly.
*/
class PersistentTree private constructor(
private val coroutineScope: CoroutineScope,
private val logFile: File
) : Timber.Tree() {
private val dateFormat = object : ThreadLocal<SimpleDateFormat>() {
override fun initialValue(): SimpleDateFormat =
SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
}
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
val timestamp = dateFormat.get()!!.format(System.currentTimeMillis())
coroutineScope.launch(Dispatchers.IO) {
val priorityLetter = when (priority) {
Log.ASSERT -> "A"
Log.DEBUG -> "D"
Log.ERROR -> "E"
Log.INFO -> "I"
Log.VERBOSE -> "V"
Log.WARN -> "W"
else -> "U"
}
FileOutputStream(logFile, true).use { stream ->
stream.bufferedWriter().use {
it.appendLine("$timestamp $priorityLetter/${tag ?: "SimpleMarkdown"}: $message")
}
t?.let {
PrintStream(stream).use { pStream ->
it.printStackTrace(pStream)
}
}
}
}
}
init {
log(Log.INFO, "Persistent logging initialized, writing contents to ${logFile.absolutePath}")
}
companion object {
/**
* Create a new instance of a [PersistentTree].
* @param logDir A [File] pointing to a directory where the log files should be stored. Will be
* created if it doesn't exist.
* @throws IllegalArgumentException if [logDir] is a file instead of a directory
* @throws IOException if the directory does not exist or cannot be
* created/written to
*/
@Throws(IllegalArgumentException::class, IOException::class)
suspend fun create(coroutineScope: CoroutineScope, logDir: File): PersistentTree = withContext(Dispatchers.IO) {
if (!logDir.mkdirs() && !logDir.isDirectory)
throw IllegalArgumentException("Unable to create log directory at ${logDir.absolutePath}")
val timestamp = SimpleDateFormat("yyyyMMddHHmmss", Locale.US).format(Date())
val logFile = File(logDir, "persistent-log-$timestamp.log")
if (!logFile.createNewFile())
throw IOException("Unable to create logFile at ${logFile.absolutePath}")
PersistentTree(coroutineScope, logFile)
}
}
}

View file

@ -1,63 +0,0 @@
package com.wbrawner.simplemarkdown.utility
import android.content.Context
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
interface PreferenceHelper {
operator fun get(preference: Preference): Any?
operator fun set(preference: Preference, value: Any?)
fun <T> observe(preference: Preference): StateFlow<T>
}
class AndroidPreferenceHelper(context: Context, private val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.IO)): PreferenceHelper {
private val sharedPreferences by lazy {
PreferenceManager.getDefaultSharedPreferences(context)
}
private val states by lazy {
val allPrefs: Map<String, Any?> = sharedPreferences.all
Preference.entries.associateWith { preference ->
MutableStateFlow(allPrefs[preference.key] ?: preference.default)
}
}
override fun get(preference: Preference): Any? = states[preference]?.value
override fun set(preference: Preference, value: Any?) {
sharedPreferences.edit {
when (value) {
is Boolean -> putBoolean(preference.key, value)
is Float -> putFloat(preference.key, value)
is Int -> putInt(preference.key, value)
is Long -> putLong(preference.key, value)
is String -> putString(preference.key, value)
null -> remove(preference.key)
}
}
coroutineScope.launch {
states[preference]!!.emit(value)
}
}
@Suppress("UNCHECKED_CAST")
override fun <T> observe(preference: Preference): StateFlow<T> = states[preference]!!.asStateFlow() as StateFlow<T>
}
enum class Preference(val key: String, val default: Any?) {
ANALYTICS_ENABLED("analytics.enable", true),
AUTOSAVE_ENABLED("autosave", true),
AUTOSAVE_URI("autosave.uri", null),
CUSTOM_CSS("pref.custom_css", null),
DARK_MODE("darkMode", "Auto"),
ERROR_REPORTS_ENABLED("acra.enable", true),
LOCK_SWIPING("lockSwiping", false),
READABILITY_ENABLED("readability.enable", false)
}

View file

@ -0,0 +1,28 @@
package com.wbrawner.simplemarkdown.view
import android.annotation.SuppressLint
import android.content.Context
import android.util.AttributeSet
import android.view.MotionEvent
import androidx.viewpager.widget.ViewPager
class DisableableViewPager : ViewPager {
private var isSwipeLocked = false
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
return !isSwipeLocked && super.onInterceptTouchEvent(ev)
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(ev: MotionEvent): Boolean {
return !isSwipeLocked && super.onTouchEvent(ev)
}
fun setSwipeLocked(locked: Boolean) {
this.isSwipeLocked = locked
}
}

View file

@ -0,0 +1,6 @@
package com.wbrawner.simplemarkdown.view
interface ViewPagerPage {
fun onSelected()
fun onDeselected()
}

View file

@ -0,0 +1,39 @@
package com.wbrawner.simplemarkdown.view.activity
import android.os.Bundle
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.navigation.findNavController
import com.wbrawner.simplemarkdown.R
import com.wbrawner.simplemarkdown.viewmodel.MarkdownViewModel
import kotlinx.coroutines.*
import kotlin.coroutines.CoroutineContext
class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsResultCallback, CoroutineScope {
override val coroutineContext: CoroutineContext = Dispatchers.Main
private val viewModel: MarkdownViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
intent?.data?.let {
launch {
viewModel.load(this@MainActivity, it)
intent?.data = null
}
}
}
override fun onBackPressed() {
findNavController(R.id.content).navigateUp()
}
override fun onDestroy() {
super.onDestroy()
coroutineContext[Job]?.let {
cancel()
}
}
}

View file

@ -0,0 +1,69 @@
package com.wbrawner.simplemarkdown.view.activity
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.preference.PreferenceManager
import com.wbrawner.simplemarkdown.R
import kotlinx.coroutines.*
import kotlin.coroutines.CoroutineContext
class SplashActivity : AppCompatActivity(), CoroutineScope {
override val coroutineContext: CoroutineContext = Dispatchers.Main
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
launch {
val darkMode = withContext(Dispatchers.IO) {
val darkModeValue = PreferenceManager.getDefaultSharedPreferences(this@SplashActivity)
.getString(
getString(R.string.pref_key_dark_mode),
getString(R.string.pref_value_auto)
)
return@withContext when {
darkModeValue.equals(getString(R.string.pref_value_light), ignoreCase = true) -> AppCompatDelegate.MODE_NIGHT_NO
darkModeValue.equals(getString(R.string.pref_value_dark), ignoreCase = true) -> AppCompatDelegate.MODE_NIGHT_YES
else -> {
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY
} else {
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
}
}
}
}
AppCompatDelegate.setDefaultNightMode(darkMode)
val uri = withContext(Dispatchers.IO) {
intent?.data
?: PreferenceManager.getDefaultSharedPreferences(this@SplashActivity)
.getString(
getString(R.string.pref_key_autosave_uri),
null
)?.let {
Uri.parse(it)
}
}
val startIntent = Intent(this@SplashActivity, MainActivity::class.java)
.apply {
data = uri
}
startActivity(startIntent)
finish()
}
}
override fun onDestroy() {
super.onDestroy()
coroutineContext[Job]?.let {
cancel()
}
}
}

View file

@ -0,0 +1,70 @@
package com.wbrawner.simplemarkdown.view.adapter
import android.content.Context
import android.content.res.Configuration
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentPagerAdapter
import androidx.viewpager.widget.ViewPager
import com.wbrawner.simplemarkdown.R
import com.wbrawner.simplemarkdown.view.fragment.EditFragment
import com.wbrawner.simplemarkdown.view.fragment.PreviewFragment
class EditPagerAdapter(fm: FragmentManager, private val context: Context)
: FragmentPagerAdapter(fm, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT), ViewPager.OnPageChangeListener {
private val editFragment = EditFragment()
private val previewFragment = PreviewFragment()
override fun getItem(position: Int): Fragment {
return when (position) {
FRAGMENT_EDIT -> editFragment
FRAGMENT_PREVIEW -> previewFragment
else -> throw IllegalStateException("Attempting to get fragment for invalid page number")
}
}
override fun getCount(): Int {
return NUM_PAGES
}
override fun getPageTitle(position: Int): CharSequence? {
var stringId = 0
when (position) {
FRAGMENT_EDIT -> stringId = R.string.action_edit
FRAGMENT_PREVIEW -> stringId = R.string.action_preview
}
return context.getString(stringId)
}
override fun getPageWidth(position: Int): Float {
return if (context.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) {
0.5f
} else {
super.getPageWidth(position)
}
}
override fun onPageScrollStateChanged(state: Int) {
}
override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {
}
override fun onPageSelected(position: Int) {
when (position) {
FRAGMENT_EDIT -> {
editFragment.onSelected()
}
FRAGMENT_PREVIEW -> {
editFragment.onDeselected()
}
}
}
companion object {
const val FRAGMENT_EDIT = 0
const val FRAGMENT_PREVIEW = 1
const val NUM_PAGES = 2
}
}

View file

@ -0,0 +1,181 @@
package com.wbrawner.simplemarkdown.view.fragment
import android.annotation.SuppressLint
import android.graphics.Color
import android.os.Bundle
import android.text.Editable
import android.text.SpannableString
import android.text.TextWatcher
import android.text.style.BackgroundColorSpan
import android.util.Log
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.EditText
import android.widget.TextView
import androidx.core.widget.NestedScrollView
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.Observer
import androidx.preference.PreferenceManager
import com.wbrawner.simplemarkdown.R
import com.wbrawner.simplemarkdown.model.Readability
import com.wbrawner.simplemarkdown.utility.hideKeyboard
import com.wbrawner.simplemarkdown.utility.showKeyboard
import com.wbrawner.simplemarkdown.view.ViewPagerPage
import com.wbrawner.simplemarkdown.viewmodel.EditorCommand
import com.wbrawner.simplemarkdown.viewmodel.MarkdownViewModel
import kotlinx.coroutines.*
import kotlin.coroutines.CoroutineContext
import kotlin.math.abs
class EditFragment : Fragment(), ViewPagerPage, CoroutineScope {
private var markdownEditor: EditText? = null
private var markdownEditorScroller: NestedScrollView? = null
private val viewModel: MarkdownViewModel by activityViewModels()
override val coroutineContext: CoroutineContext = Dispatchers.Main
private var readabilityWatcher: TextWatcher? = null
@SuppressLint("ClickableViewAccessibility")
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View? =
inflater.inflate(R.layout.fragment_edit, container, false)
@SuppressLint("ClickableViewAccessibility")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
markdownEditor = view.findViewById(R.id.markdown_edit)
markdownEditorScroller = view.findViewById(R.id.markdown_edit_container)
markdownEditor?.addTextChangedListener(object : TextWatcher {
private var searchFor = ""
override fun afterTextChanged(s: Editable?) {
val searchText = s.toString().trim()
if (searchText == searchFor)
return
searchFor = searchText
launch {
delay(50)
if (searchText != searchFor)
return@launch
viewModel.updateMarkdown(searchText)
}
}
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
}
})
var touchDown = 0L
var oldX = 0f
var oldY = 0f
markdownEditorScroller!!.setOnTouchListener { _, event ->
// The ScrollView's onClickListener doesn't seem to be called, so I've had to
// implement a sort of custom click listener that checks that the tap was both quick
// and didn't drag.
when (event?.action) {
MotionEvent.ACTION_DOWN -> {
touchDown = System.currentTimeMillis()
oldX = event.rawX
oldY = event.rawY
}
MotionEvent.ACTION_UP -> {
if (System.currentTimeMillis() - touchDown < 150
&& abs(event.rawX - oldX) < 25
&& abs(event.rawY - oldY) < 25)
markdownEditor?.showKeyboard()
}
}
false
}
markdownEditor?.setText(viewModel.markdown.value)
viewModel.editorCommands.observe(viewLifecycleOwner, Observer {
when (it) {
is EditorCommand.Undo -> markdownEditor?.text?.
}
markdownEditor?.setText(it)
})
launch {
val enableReadability = withContext(Dispatchers.IO) {
context?.let {
PreferenceManager.getDefaultSharedPreferences(it)
.getBoolean(getString(R.string.readability_enabled), false)
}?: false
}
if (enableReadability) {
if (readabilityWatcher == null) {
readabilityWatcher = ReadabilityTextWatcher()
}
markdownEditor?.addTextChangedListener(readabilityWatcher)
} else {
readabilityWatcher?.let {
markdownEditor?.removeTextChangedListener(it)
}
readabilityWatcher = null
}
}
}
override fun onDestroyView() {
coroutineContext[Job]?.let {
cancel()
}
super.onDestroyView()
}
override fun onSelected() {
markdownEditor?.showKeyboard()
}
override fun onDeselected() {
markdownEditor?.hideKeyboard()
}
inner class ReadabilityTextWatcher : TextWatcher {
private var previousValue = ""
private var searchFor = ""
override fun afterTextChanged(s: Editable?) {
val searchText = s.toString().trim()
if (searchText == searchFor)
return
searchFor = searchText
launch {
delay(250)
if (searchText != searchFor)
return@launch
val start = System.currentTimeMillis()
if (searchFor.isEmpty()) return@launch
if (previousValue == searchFor) return@launch
val readability = Readability(searchFor)
val span = SpannableString(searchFor)
for (sentence in readability.sentences()) {
var color = Color.TRANSPARENT
if (sentence.syllableCount() > 25) color = Color.argb(100, 229, 232, 42)
if (sentence.syllableCount() > 35) color = Color.argb(100, 193, 66, 66)
Log.d("SimpleMarkdown", "Sentence start: ${sentence.start()} end: ${sentence.end()}")
span.setSpan(BackgroundColorSpan(color), sentence.start(), sentence.end(), 0)
}
markdownEditor?.setTextKeepState(span, TextView.BufferType.SPANNABLE)
previousValue = searchFor
val timeTakenMs = System.currentTimeMillis() - start
Log.d("SimpleMarkdown", "Handled markdown in $timeTakenMs ms")
}
}
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
}
}
}

View file

@ -0,0 +1,301 @@
package com.wbrawner.simplemarkdown.view.fragment
import android.Manifest
import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.webkit.MimeTypeMap
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.Observer
import androidx.navigation.fragment.findNavController
import androidx.navigation.ui.AppBarConfiguration
import androidx.navigation.ui.onNavDestinationSelected
import androidx.navigation.ui.setupWithNavController
import androidx.preference.PreferenceManager
import com.wbrawner.simplemarkdown.MarkdownApplication
import com.wbrawner.simplemarkdown.R
import com.wbrawner.simplemarkdown.view.adapter.EditPagerAdapter
import com.wbrawner.simplemarkdown.viewmodel.MarkdownViewModel
import kotlinx.android.synthetic.main.fragment_main.*
import kotlinx.coroutines.*
import java.io.File
import kotlin.coroutines.CoroutineContext
class MainFragment : Fragment(), ActivityCompat.OnRequestPermissionsResultCallback, CoroutineScope {
private var shouldAutoSave = true
override val coroutineContext: CoroutineContext = Dispatchers.Main
private val viewModel: MarkdownViewModel by activityViewModels()
private var appBarConfiguration: AppBarConfiguration? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? =
inflater.inflate(R.layout.fragment_main, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
with(findNavController()) {
appBarConfiguration = AppBarConfiguration(graph, drawerLayout)
toolbar.setupWithNavController(this, appBarConfiguration!!)
toolbar.inflateMenu(R.menu.menu_edit)
toolbar.setOnMenuItemClickListener { item ->
return@setOnMenuItemClickListener when (item.itemId) {
R.id.action_save -> {
launch {
if (!viewModel.save(requireContext())) {
requestFileOp(REQUEST_SAVE_FILE)
} else {
Toast.makeText(
requireContext(),
getString(R.string.file_saved, viewModel.fileName.value),
Toast.LENGTH_SHORT
).show()
}
}
true
}
R.id.action_save_as -> {
requestFileOp(REQUEST_SAVE_FILE)
true
}
R.id.action_share -> {
val shareIntent = Intent(Intent.ACTION_SEND)
shareIntent.putExtra(Intent.EXTRA_TEXT, viewModel.markdown.value)
shareIntent.type = "text/plain"
startActivity(Intent.createChooser(
shareIntent,
getString(R.string.share_file)
))
true
}
R.id.action_load -> {
requestFileOp(REQUEST_OPEN_FILE)
true
}
R.id.action_new -> {
promptSaveOrDiscardChanges()
true
}
R.id.action_lock_swipe -> {
item.isChecked = !item.isChecked
pager!!.setSwipeLocked(item.isChecked)
true
}
else -> item.onNavDestinationSelected(findNavController())
}
}
navigationView.setupWithNavController(this)
}
val adapter = EditPagerAdapter(childFragmentManager, view.context)
pager.adapter = adapter
pager.addOnPageChangeListener(adapter)
pager.pageMargin = 1
pager.setPageMarginDrawable(R.color.colorAccent)
tabLayout.setupWithViewPager(pager)
if (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) {
tabLayout!!.visibility = View.GONE
}
@Suppress("CAST_NEVER_SUCCEEDS")
viewModel.fileName.observe(viewLifecycleOwner, Observer {
toolbar?.title = it
})
}
override fun onStart() {
super.onStart()
launch {
withContext(Dispatchers.IO) {
val enableErrorReports = PreferenceManager.getDefaultSharedPreferences(requireContext())
.getBoolean(getString(R.string.error_reports_enabled), true)
(requireActivity().application as MarkdownApplication).errorHandler.enable(enableErrorReports)
}
}
}
override fun onPause() {
super.onPause()
launch {
val context = context?.applicationContext ?: return@launch
withContext(Dispatchers.IO) {
val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(context)
val isAutoSaveEnabled = sharedPrefs.getBoolean(KEY_AUTOSAVE, true)
if (!shouldAutoSave || !isAutoSaveEnabled) {
return@withContext
}
val uri = if (viewModel.save(context)) {
viewModel.uri.value
} else {
// The user has left the app, with autosave enabled, and we don't already have a
// Uri for them or for some reason we were unable to save to the original Uri. In
// this case, we need to just save to internal file storage so that we can recover
val fileUri = Uri.fromFile(File(context.filesDir, viewModel.fileName.value!!))
if (viewModel.save(context, fileUri)) {
fileUri
} else {
null
}
} ?: return@withContext
sharedPrefs.edit()
.putString(getString(R.string.pref_key_autosave_uri), uri.toString())
.apply()
}
}
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
if (newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE)
tabLayout!!.visibility = View.GONE
else
tabLayout!!.visibility = View.VISIBLE
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray
) {
when (requestCode) {
REQUEST_SAVE_FILE, REQUEST_OPEN_FILE -> {
// If request is cancelled, the result arrays are empty.
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
// Permission granted, open file save dialog
requestFileOp(requestCode)
} else {
// Permission denied, do nothing
context?.let {
Toast.makeText(it, R.string.no_permissions, Toast.LENGTH_SHORT)
.show()
}
}
}
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
when (requestCode) {
REQUEST_OPEN_FILE -> {
if (resultCode != Activity.RESULT_OK || data?.data == null) {
return
}
launch {
val fileLoaded = context?.let {
viewModel.load(it, data.data)
}
if (fileLoaded == false) {
context?.let {
Toast.makeText(it, R.string.file_load_error, Toast.LENGTH_SHORT)
.show()
}
}
}
}
REQUEST_SAVE_FILE -> {
if (resultCode != Activity.RESULT_OK || data?.data == null) {
return
}
launch {
context?.let {
viewModel.save(it, data.data)
}
}
}
}
super.onActivityResult(requestCode, resultCode, data)
}
private fun promptSaveOrDiscardChanges() {
if (viewModel.originalMarkdown.value == viewModel.markdown.value) {
viewModel.reset("Untitled.md")
return
}
val context = context ?: return
AlertDialog.Builder(context)
.setTitle(R.string.save_changes)
.setMessage(R.string.prompt_save_changes)
.setNegativeButton(R.string.action_discard) { _, _ ->
viewModel.reset("Untitled.md")
}
.setPositiveButton(R.string.action_save) { _, _ ->
requestFileOp(REQUEST_SAVE_FILE)
}
.create()
.show()
}
private fun requestFileOp(requestType: Int) {
val context = context ?: return
if (ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE)
!= PackageManager.PERMISSION_GRANTED && Build.VERSION.SDK_INT > 22) {
requestPermissions(
arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE),
requestType
)
return
}
// If the user is going to save the file, we don't want to auto-save it for them
shouldAutoSave = false
val intent = when (requestType) {
REQUEST_SAVE_FILE -> {
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
type = "text/markdown"
putExtra(Intent.EXTRA_TITLE, viewModel.fileName.value)
}
}
REQUEST_OPEN_FILE -> {
Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
type = "*/*"
if (MimeTypeMap.getSingleton().hasMimeType("md")) {
// If the device doesn't recognize markdown files then we're not going to be
// able to open them at all, so there's no sense in filtering them out.
putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("text/plain", "text/markdown"))
}
}
}
else -> null
} ?: return
intent.addCategory(Intent.CATEGORY_OPENABLE)
startActivityForResult(
intent,
requestType
)
}
override fun onResume() {
super.onResume()
shouldAutoSave = true
}
override fun onDestroy() {
super.onDestroy()
coroutineContext[Job]?.let {
cancel()
}
}
companion object {
// Request codes
const val REQUEST_OPEN_FILE = 1
const val REQUEST_SAVE_FILE = 2
const val REQUEST_DARK_MODE = 4
const val KEY_AUTOSAVE = "autosave"
}
}

View file

@ -0,0 +1,92 @@
package com.wbrawner.simplemarkdown.view.fragment
import android.content.res.Configuration
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.appcompat.app.AppCompatDelegate
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import androidx.navigation.ui.setupWithNavController
import com.wbrawner.simplemarkdown.MarkdownApplication
import com.wbrawner.simplemarkdown.R
import com.wbrawner.simplemarkdown.utility.readAssetToString
import com.wbrawner.simplemarkdown.utility.toHtml
import kotlinx.android.synthetic.main.fragment_markdown_info.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlin.coroutines.CoroutineContext
class MarkdownInfoFragment : Fragment(), CoroutineScope {
override val coroutineContext: CoroutineContext = Dispatchers.Main
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? =
inflater.inflate(R.layout.fragment_markdown_info, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val fileName = arguments?.getString(EXTRA_FILE)
if (fileName.isNullOrBlank()) {
findNavController().navigateUp()
return
}
toolbar.setupWithNavController(findNavController())
val isNightMode = AppCompatDelegate.getDefaultNightMode() ==
AppCompatDelegate.MODE_NIGHT_YES
|| resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
val defaultCssId = if (isNightMode) {
R.string.pref_custom_css_default_dark
} else {
R.string.pref_custom_css_default
}
val css: String? = getString(defaultCssId)
launch {
try {
val html = view.context.assets?.readAssetToString(fileName)
?.toHtml()
?: throw RuntimeException("Unable to open stream to $fileName")
infoWebview.loadDataWithBaseURL(null,
String.format(FORMAT_CSS, css) + html,
"text/html",
"UTF-8", null
)
} catch (e: Exception) {
(requireActivity().application as MarkdownApplication).errorHandler.reportException(e)
Toast.makeText(view.context, R.string.file_load_error, Toast.LENGTH_SHORT).show()
findNavController().navigateUp()
}
}
}
override fun onDestroy() {
coroutineContext[Job]?.cancel()
super.onDestroy()
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == android.R.id.home) {
findNavController().navigateUp()
return true
}
return super.onOptionsItemSelected(item)
}
companion object {
const val FORMAT_CSS = "<style>" +
"%s" +
"</style>"
const val EXTRA_TITLE = "title"
const val EXTRA_FILE = "file"
}
}

View file

@ -0,0 +1,101 @@
package com.wbrawner.simplemarkdown.view.fragment
import android.content.Context
import android.content.res.Configuration.UI_MODE_NIGHT_MASK
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.webkit.WebView
import androidx.appcompat.app.AppCompatDelegate
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.Observer
import androidx.preference.PreferenceManager
import com.wbrawner.simplemarkdown.BuildConfig
import com.wbrawner.simplemarkdown.R
import com.wbrawner.simplemarkdown.utility.toHtml
import com.wbrawner.simplemarkdown.viewmodel.MarkdownViewModel
import kotlinx.coroutines.*
import kotlin.coroutines.CoroutineContext
class PreviewFragment : Fragment(), CoroutineScope {
override val coroutineContext: CoroutineContext = Dispatchers.Main
private val viewModel: MarkdownViewModel by activityViewModels()
private var markdownPreview: WebView? = null
private var style: String = ""
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? = inflater.inflate(R.layout.fragment_preview, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
markdownPreview = view.findViewById(R.id.markdown_view)
WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG)
launch {
val isNightMode = AppCompatDelegate.getDefaultNightMode() ==
AppCompatDelegate.MODE_NIGHT_YES
|| requireContext().resources.configuration.uiMode and UI_MODE_NIGHT_MASK == UI_MODE_NIGHT_YES
val defaultCssId = if (isNightMode) {
R.string.pref_custom_css_default_dark
} else {
R.string.pref_custom_css_default
}
val css = withContext(Dispatchers.IO) {
val context = context ?: return@withContext null
@Suppress("ConstantConditionIf")
if (!BuildConfig.ENABLE_CUSTOM_CSS) {
context.getString(defaultCssId)
} else {
PreferenceManager.getDefaultSharedPreferences(context)
.getString(
getString(R.string.pref_custom_css),
getString(defaultCssId)
)
}
}
style = String.format(FORMAT_CSS, css ?: "")
}
}
override fun onAttach(context: Context) {
super.onAttach(context)
updateWebContent(viewModel.markdown.value ?: "")
viewModel.markdown.observe(this, Observer {
updateWebContent(it)
})
}
private fun updateWebContent(markdown: String) {
markdownPreview?.post {
launch {
markdownPreview?.loadDataWithBaseURL(null,
style + markdown.toHtml(),
"text/html",
"UTF-8", null
)
}
}
}
override fun onDestroyView() {
coroutineContext[Job]?.let {
cancel()
}
markdownPreview?.let {
(it.parent as ViewGroup).removeView(it)
it.destroy()
markdownPreview = null
}
super.onDestroyView()
}
companion object {
var FORMAT_CSS = "<style>" +
"%s" +
"</style>"
}
}

View file

@ -0,0 +1,28 @@
package com.wbrawner.simplemarkdown.view.fragment
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import androidx.navigation.ui.setupWithNavController
import com.wbrawner.simplemarkdown.R
import kotlinx.android.synthetic.main.fragment_settings.*
class SettingsContainerFragment : Fragment() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? =
inflater.inflate(R.layout.fragment_settings, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
toolbar.setupWithNavController(findNavController())
}
override fun onOptionsItemSelected(item: MenuItem): Boolean = findNavController().navigateUp()
}

View file

@ -0,0 +1,75 @@
package com.wbrawner.simplemarkdown.view.fragment
import android.content.SharedPreferences
import android.os.Build
import android.os.Bundle
import androidx.appcompat.app.AppCompatDelegate
import androidx.preference.ListPreference
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.PreferenceManager
import com.wbrawner.simplemarkdown.BuildConfig
import com.wbrawner.simplemarkdown.R
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlin.coroutines.CoroutineContext
class SettingsFragment
: PreferenceFragmentCompat(),
SharedPreferences.OnSharedPreferenceChangeListener,
CoroutineScope {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_general)
}
override val coroutineContext: CoroutineContext = Dispatchers.Main
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
launch(context = Dispatchers.IO) {
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(activity)
sharedPreferences.registerOnSharedPreferenceChangeListener(this@SettingsFragment)
(findPreference(getString(R.string.pref_key_dark_mode)) as? ListPreference)?.let {
setListPreferenceSummary(sharedPreferences, it)
}
@Suppress("ConstantConditionIf")
if (!BuildConfig.ENABLE_CUSTOM_CSS) {
preferenceScreen.removePreference(findPreference(getString(R.string.pref_custom_css)))
}
}
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
if (!isAdded) return
val preference = findPreference(key) as? ListPreference ?: return
setListPreferenceSummary(sharedPreferences, preference)
if (preference.key != getString(R.string.pref_key_dark_mode)) {
return
}
var darkMode: Int = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY
} else {
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
}
val darkModeValue = sharedPreferences.getString(preference.key, null)
if (darkModeValue != null && darkModeValue.isNotEmpty()) {
if (darkModeValue.equals(getString(R.string.pref_value_light), ignoreCase = true)) {
darkMode = AppCompatDelegate.MODE_NIGHT_NO
} else if (darkModeValue.equals(getString(R.string.pref_value_dark), ignoreCase = true)) {
darkMode = AppCompatDelegate.MODE_NIGHT_YES
}
}
AppCompatDelegate.setDefaultNightMode(darkMode)
activity?.recreate()
}
private fun setListPreferenceSummary(sharedPreferences: SharedPreferences, preference: ListPreference) {
val storedValue = sharedPreferences.getString(
preference.key,
null
) ?: return
val index = preference.findIndexOfValue(storedValue)
if (index < 0) return
preference.summary = preference.entries[index].toString()
}
}

View file

@ -0,0 +1,61 @@
package com.wbrawner.simplemarkdown.view.fragment
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.browser.customtabs.CustomTabsIntent
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.navigation.fragment.findNavController
import androidx.navigation.ui.setupWithNavController
import com.wbrawner.simplemarkdown.R
import com.wbrawner.simplemarkdown.utility.SupportLinkProvider
import kotlinx.android.synthetic.main.fragment_support.*
class SupportFragment : Fragment() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? =
inflater.inflate(R.layout.fragment_support, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
toolbar.setupWithNavController(findNavController())
githubButton.setOnClickListener {
CustomTabsIntent.Builder()
.addDefaultShareMenuItem()
.build()
.launchUrl(view.context, Uri.parse("https://github" +
".com/wbrawner/SimpleMarkdown"))
}
rateButton.setOnClickListener {
val playStoreIntent = Intent(Intent.ACTION_VIEW)
.apply {
data = Uri.parse("market://details?id=${view.context.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY or
Intent.FLAG_ACTIVITY_NEW_DOCUMENT or
Intent.FLAG_ACTIVITY_MULTIPLE_TASK)
}
try {
startActivity(playStoreIntent)
} catch (ignored: ActivityNotFoundException) {
playStoreIntent.data = Uri.parse("https://play.google.com/store/apps/details?id=${view.context.packageName}")
startActivity(playStoreIntent)
}
}
SupportLinkProvider(requireActivity()).supportLinks.observe(viewLifecycleOwner, Observer { links ->
links.forEach {
supportButtons.addView(it)
}
})
}
// override fun onOptionsItemSelected(item: MenuItem): Boolean {
// if (item.itemId == android.R.id.home) {
// findNavController().navigateUp()
// return true
// }
// return super.onOptionsItemSelected(item)
// }
}

View file

@ -0,0 +1,84 @@
package com.wbrawner.simplemarkdown.viewmodel
import android.content.Context
import android.net.Uri
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.wbrawner.simplemarkdown.utility.getName
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.FileInputStream
import java.io.Reader
class MarkdownViewModel : ViewModel() {
val fileName = MutableLiveData<String>("Untitled.md")
val editorCommands = MutableLiveData<EditorCommand>()
val markdown = MutableLiveData<String>()
val uri = MutableLiveData<Uri>()
fun updateMarkdown(markdown: String?) {
this.markdown.postValue(markdown ?: "")
}
suspend fun load(context: Context, uri: Uri?): Boolean {
if (uri == null) return false
return withContext(Dispatchers.IO) {
try {
context.contentResolver.openFileDescriptor(uri, "r")?.use {
val fileInput = FileInputStream(it.fileDescriptor)
val fileName = uri.getName(context)
val content = fileInput.reader().use(Reader::readText)
markdown.postValue(content)
editorCommands.postValue(EditorCommand.Load(content))
this@MarkdownViewModel.fileName.postValue(fileName)
this@MarkdownViewModel.uri.postValue(uri)
true
} ?: false
} catch (ignored: Exception) {
false
}
}
}
suspend fun save(context: Context, givenUri: Uri? = this.uri.value): Boolean {
val uri = givenUri ?: this.uri.value ?: return false
return withContext(Dispatchers.IO) {
try {
val fileName = uri.getName(context)
context.contentResolver.openOutputStream(uri, "rwt")
?.writer()
?.use {
it.write(markdown.value ?: "")
}
?: return@withContext false
this@MarkdownViewModel.fileName.postValue(fileName)
this@MarkdownViewModel.uri.postValue(uri)
true
} catch (ignored: Exception) {
false
}
}
}
fun reset(untitledFileName: String) {
fileName.postValue(untitledFileName)
editorCommands.postValue(EditorCommand.Load(""))
markdown.postValue("")
}
fun undo() {
editorCommands.postValue(EditorCommand.Undo())
}
fun redo() {
editorCommands.postValue(EditorCommand.Redo())
}
}
sealed class EditorCommand {
val consumed = false
class Undo: EditorCommand()
class Redo: EditorCommand()
class Load(val text: String): EditorCommand()
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportHeight="24.0"
android:viewportWidth="24.0">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M9,16.2L4.8,12l-1.4,1.4L9,19 21,7l-1.4,-1.4L9,16.2z" />
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M12,4.5C7,4.5 2.73,7.61 1,12c1.73,4.39 6,7.5 11,7.5s9.27,-3.11 11,-7.5c-1.73,-4.39 -6,-7.5 -11,-7.5zM12,17c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5 -2.24,5 -5,5zM12,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3 3,-1.34 3,-3 -1.34,-3 -3,-3z" />
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M12,21.35l-1.45,-1.32C5.4,15.36 2,12.28 2,8.5 2,5.42 4.42,3 7.5,3c1.74,0 3.41,0.81 4.5,2.09C13.09,3.81 14.76,3 16.5,3 19.58,3 22,5.42 22,8.5c0,3.78 -3.4,6.86 -8.55,11.54L12,21.35z" />
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,19h-2v-2h2v2zM15.07,11.25l-0.9,0.92C13.45,12.9 13,13.5 13,15h-2v-0.5c0,-1.1 0.45,-2.1 1.17,-2.83l1.24,-1.26c0.37,-0.36 0.59,-0.86 0.59,-1.41 0,-1.1 -0.9,-2 -2,-2s-2,0.9 -2,2L8,9c0,-2.21 1.79,-4 4,-4s4,1.79 4,4c0,0.88 -0.36,1.68 -0.93,2.25z" />
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,17h-2v-6h2v6zM13,9h-2L11,7h2v2z" />
</vector>

View file

@ -1,24 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group
android:scaleX="0.39862207"
android:scaleY="0.39862207"
android:translateX="27"
android:translateY="27">
<path
android:fillColor="#FFFFFF"
android:pathData="M55.033,2.91h25.4v129.646h-25.4z"
android:strokeWidth="0.53079969" />
<path
android:fillColor="#FFFFFF"
android:pathData="M2.725,44.865l12.7,-21.997l117.318,67.733l-12.7,21.997z"
android:strokeWidth="0.54258478" />
<path
android:fillColor="#FFFFFF"
android:pathData="M120.042,22.868l12.7,21.997l-117.318,67.733l-12.7,-21.997z"
android:strokeWidth="0.54258478" />
</group>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@color/colorOnBackground"
android:pathData="M3,18h18v-2L3,16v2zM3,13h18v-2L3,11v2zM3,6v2h18L21,6L3,6z" />
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M18.4,10.6C16.55,8.99 14.15,8 11.5,8c-4.65,0 -8.58,3.03 -9.96,7.22L3.9,16c1.05,-3.19 4.05,-5.5 7.6,-5.5 1.95,0 3.73,0.72 5.12,1.88L13,16h9V7l-3.6,3.6z"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M19.1,12.9a2.8,2.8 0,0 0,0.1 -0.9,2.8 2.8,0 0,0 -0.1,-0.9l2.1,-1.6a0.7,0.7 0,0 0,0.1 -0.6L19.4,5.5a0.7,0.7 0,0 0,-0.6 -0.2l-2.4,1a6.5,6.5 0,0 0,-1.6 -0.9l-0.4,-2.6a0.5,0.5 0,0 0,-0.5 -0.4H10.1a0.5,0.5 0,0 0,-0.5 0.4L9.3,5.4a5.6,5.6 0,0 0,-1.7 0.9l-2.4,-1a0.4,0.4 0,0 0,-0.5 0.2l-2,3.4c-0.1,0.2 0,0.4 0.2,0.6l2,1.6a2.8,2.8 0,0 0,-0.1 0.9,2.8 2.8,0 0,0 0.1,0.9L2.8,14.5a0.7,0.7 0,0 0,-0.1 0.6l1.9,3.4a0.7,0.7 0,0 0,0.6 0.2l2.4,-1a6.5,6.5 0,0 0,1.6 0.9l0.4,2.6a0.5,0.5 0,0 0,0.5 0.4h3.8a0.5,0.5 0,0 0,0.5 -0.4l0.3,-2.6a5.6,5.6 0,0 0,1.7 -0.9l2.4,1a0.4,0.4 0,0 0,0.5 -0.2l2,-3.4c0.1,-0.2 0,-0.4 -0.2,-0.6ZM12,15.6A3.6,3.6 0,1 1,15.6 12,3.6 3.6,0 0,1 12,15.6Z" />
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@color/colorOnBackground"
android:pathData="M18,16.08c-0.76,0 -1.44,0.3 -1.96,0.77L8.91,12.7c0.05,-0.23 0.09,-0.46 0.09,-0.7s-0.04,-0.47 -0.09,-0.7l7.05,-4.11c0.54,0.5 1.25,0.81 2.04,0.81 1.66,0 3,-1.34 3,-3s-1.34,-3 -3,-3 -3,1.34 -3,3c0,0.24 0.04,0.47 0.09,0.7L8.04,9.81C7.5,9.31 6.79,9 6,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3c0.79,0 1.5,-0.31 2.04,-0.81l7.12,4.16c-0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.61 1.31,2.92 2.92,2.92 1.61,0 2.92,-1.31 2.92,-2.92s-1.31,-2.92 -2.92,-2.92z" />
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M12.5,8c-2.65,0 -5.05,0.99 -6.9,2.6L2,7v9h9l-3.62,-3.62c1.39,-1.16 3.16,-1.88 5.12,-1.88 3.54,0 6.55,2.31 7.6,5.5l2.37,-0.78C21.08,11.03 17.15,8 12.5,8z"/>
</vector>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:top="0dp"
android:bottom="0dp"
android:left="0dp"
android:right="0dp"
android:drawable="@color/colorPrimary"/>
<item
android:drawable="@drawable/splash_fg"
android:gravity="center"/>
</layer-list>

View file

@ -0,0 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.drawerlayout.widget.DrawerLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/drawerLayout">
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.wbrawner.simplemarkdown.view.DisableableViewPager
android:id="@+id/pager"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/bottomSheet"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="@color/colorBackground"
app:layout_scrollFlags="scroll|enterAlways" />
<com.google.android.material.tabs.TabLayout
android:id="@+id/tabLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/colorBackground"
android:visibility="gone">
<com.google.android.material.tabs.TabItem
android:id="@+id/editTab"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/action_edit" />
<com.google.android.material.tabs.TabItem
android:id="@+id/previewTab"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/action_preview" />
</com.google.android.material.tabs.TabLayout>
</com.google.android.material.appbar.AppBarLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
<com.google.android.material.navigation.NavigationView
android:id="@+id/navigationView"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="start"
android:fitsSystemWindows="true"
app:menu="@menu/menu_main" />
</androidx.drawerlayout.widget.DrawerLayout>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.fragment.app.FragmentContainerView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/content"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:defaultNavHost="true"
app:navGraph="@navigation/nav_graph" />

View file

@ -0,0 +1,23 @@
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/markdown_edit_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.wbrawner.simplemarkdown.view.fragment.EditFragment">
<EditText
android:id="@+id/markdown_edit"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@null"
android:gravity="top"
android:fontFamily="monospace"
android:hint="@string/markdown_here"
android:imeOptions="flagNoExtractUi"
android:inputType="textMultiLine|textCapSentences"
android:paddingLeft="8dp"
android:paddingRight="8dp"
android:paddingBottom="16dp"
android:scrollHorizontally="false"
android:importantForAutofill="no" />
</androidx.core.widget.NestedScrollView>

View file

@ -0,0 +1,63 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.drawerlayout.widget.DrawerLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/colorBackground"
android:id="@+id/drawerLayout">
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.wbrawner.simplemarkdown.view.DisableableViewPager
android:id="@+id/pager"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/bottomSheet"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:liftOnScroll="true">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="@color/colorBackground"
app:layout_scrollFlags="scroll|enterAlways|snap" />
<com.google.android.material.tabs.TabLayout
android:id="@+id/tabLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:background="@color/colorBackground">
<com.google.android.material.tabs.TabItem
android:id="@+id/editTab"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/action_edit" />
<com.google.android.material.tabs.TabItem
android:id="@+id/previewTab"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/action_preview" />
</com.google.android.material.tabs.TabLayout>
</com.google.android.material.appbar.AppBarLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
<com.google.android.material.navigation.NavigationView
android:id="@+id/navigationView"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="start"
android:fitsSystemWindows="true"
app:menu="@menu/menu_main" />
</androidx.drawerlayout.widget.DrawerLayout>

View file

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.wbrawner.simplemarkdown.view.fragment.MarkdownInfoFragment">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appBarLayout"
android:layout_width="match_parent"
android:layout_height="128dp"
android:background="@color/colorBackground">
<com.google.android.material.appbar.CollapsingToolbarLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_scrollFlags="scroll|exitUntilCollapsed|snap">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:elevation="0dp"
app:layout_collapseMode="pin" />
</com.google.android.material.appbar.CollapsingToolbarLayout>
</com.google.android.material.appbar.AppBarLayout>
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<WebView
android:id="@+id/infoWebview"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:nestedScrollingEnabled="false" />
</androidx.core.widget.NestedScrollView>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -0,0 +1,13 @@
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.wbrawner.simplemarkdown.view.fragment.PreviewFragment">
<WebView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:nestedScrollingEnabled="false"
android:id="@+id/markdown_view" />
</androidx.core.widget.NestedScrollView>

View file

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appBarLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="@color/colorBackground" />
</com.google.android.material.appbar.AppBarLayout>
<androidx.fragment.app.FragmentContainerView
android:id="@+id/fragment_settings"
android:name="com.wbrawner.simplemarkdown.view.fragment.SettingsFragment"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
</LinearLayout>

View file

@ -0,0 +1,78 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="@color/colorBackground"
app:layout_constraintTop_toTopOf="parent" />
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:clipChildren="false"
android:clipToPadding="false"
android:padding="16dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@+id/toolbar">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/heartIcon"
android:layout_width="100dp"
android:layout_height="100dp"
android:src="@drawable/ic_favorite_black_24dp"
android:tint="@color/colorAccent"
android:contentDescription="@string/description_heart"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/supportInfoText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
android:text="@string/support_info"
android:textAlignment="center"
android:textColor="@color/colorOnBackground"
app:layout_constraintTop_toBottomOf="@+id/heartIcon" />
<com.google.android.material.button.MaterialButton
android:id="@+id/githubButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:backgroundTint="@color/colorBackgroundGitHub"
android:textColor="@color/colorWhite"
android:text="@string/action_view_github"
app:layout_constraintTop_toBottomOf="@+id/supportInfoText" />
<com.google.android.material.button.MaterialButton
android:id="@+id/rateButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:backgroundTint="@color/colorBackgroundPlayStore"
android:textColor="@color/colorWhite"
android:text="@string/action_rate"
app:layout_constraintTop_toBottomOf="@+id/githubButton" />
<LinearLayout
android:id="@+id/supportButtons"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:orientation="vertical"
app:layout_constraintTop_toBottomOf="@+id/rateButton" />
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_share"
android:icon="@drawable/ic_share"
android:title="@string/action_share"
app:showAsAction="ifRoom" />
<item
android:id="@+id/action_undo"
android:icon="@drawable/ic_undo"
android:title="@string/action_undo"
app:showAsAction="ifRoom" />
<item
android:id="@+id/action_redo"
android:icon="@drawable/ic_redo"
android:title="@string/action_redo"
app:showAsAction="ifRoom" />
<group android:id="@+id/editGroup">
<item
android:id="@+id/action_new"
android:title="@string/action_new"
app:showAsAction="never" />
<item
android:id="@+id/action_load"
android:title="@string/action_open"
app:showAsAction="never" />
<item
android:id="@+id/action_save"
android:title="@string/action_save" />
<item
android:id="@+id/action_save_as"
android:title="@string/action_save_as" />
</group>
<item
android:id="@+id/action_lock_swipe"
android:checkable="true"
android:title="@string/action_lock_swipe"
app:showAsAction="never" />
</menu>

View file

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<group android:id="@+id/mainGroup">
<item
android:id="@+id/action_mainFragment_to_settingsContainerFragment"
android:title="@string/action_settings"
android:icon="@drawable/ic_settings_black_24dp"
app:showAsAction="never" />
<item
android:id="@+id/action_mainFragment_to_supportFragment"
android:title="@string/support_title"
android:icon="@drawable/ic_favorite_black_24dp"
app:showAsAction="never" />
</group>
<group android:id="@+id/addtionalInfoGroup">
<item
android:id="@+id/action_mainFragment_to_helpFragment"
android:title="@string/action_help"
android:icon="@drawable/ic_help_black_24dp"
app:showAsAction="never" />
<item
android:id="@+id/action_mainFragment_to_librariesFragment"
android:title="@string/action_libraries"
android:icon="@drawable/ic_info_black_24dp"
app:showAsAction="never" />
<item
android:id="@+id/action_mainFragment_to_privacyFragment"
android:title="@string/action_privacy"
android:icon="@drawable/ic_eye_black_24dp"
app:showAsAction="never" />
</group>
</menu>

View file

@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

View file

@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 894 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Some files were not shown because too many files have changed in this diff Show more