Sample app, update check

This commit is contained in:
Dimitry Ivanov 2020-07-11 15:52:01 +03:00
parent 086494bd97
commit c96ea690f6
26 changed files with 452 additions and 22 deletions

View File

@ -3,6 +3,15 @@ apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply plugin: 'kotlin-android-extensions'
def gitSha = { ->
def output = new ByteArrayOutputStream()
exec {
commandLine 'git', 'rev-parse', '--short', 'HEAD'
standardOutput = output
}
return output.toString().trim()
}.memoize()
android {
compileSdkVersion config['compile-sdk']
@ -17,7 +26,10 @@ android {
resConfig 'en'
setProperty("archivesBaseName", "markwon-$versionName")
setProperty("archivesBaseName", "markwon")
buildConfigField 'String', 'GIT_SHA', "\"${gitSha()}\""
buildConfigField 'String', 'GIT_REPOSITORY', '"https://github.com/noties/Markwon"'
final def scheme = 'markwon'
buildConfigField 'String', 'DEEPLINK_SCHEME', "\"$scheme\""
@ -41,6 +53,37 @@ android {
java.srcDirs += '../sample-utils/annotations'
}
}
signingConfigs {
config {
final def keystoreFile = project.file('keystore.jks')
final def keystoreFilePassword = 'MARKWON_KEYSTORE_FILE_PASSWORD'
final def keystoreAlias = 'MARKWON_KEY_ALIAS'
final def keystoreAliasPassword = 'MARKWON_KEY_ALIAS_PASSWORD'
final def properties = [
keystoreFilePassword,
keystoreAlias,
keystoreAliasPassword
]
if (!keystoreFile.exists()) {
throw new IllegalStateException("No '${keystoreFile.name}' file is found.")
}
final def missingProperties = properties.findAll { !project.hasProperty(it) }
if (!missingProperties.isEmpty()) {
throw new IllegalStateException("Missing required signing properties: $missingProperties")
}
storeFile keystoreFile
storePassword project[keystoreFilePassword]
keyAlias project[keystoreAlias]
keyPassword project[keystoreAliasPassword]
}
}
}
kapt {

24
app-sample/deploy.sh Executable file
View File

@ -0,0 +1,24 @@
#!/usr/bin/env sh
# abort on errors
set -e
# build
../gradlew :app-sample:clean
../gradlew :app-sample:assembleDebug
# navigate into the build output directory
cd ./build/outputs/apk/debug/
revision=$(git rev-parse --short HEAD)
echo "output.json" > ./.gitignore
echo "$revision" > ./version
git init
git add -A
git commit -m "sample $revision"
git push -f git@github.com:noties/Markwon.git master:sample-store
cd -

BIN
app-sample/keystore.jks Normal file

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,20 @@
{
"version": 1,
"artifactType": {
"type": "APK",
"kind": "Directory"
},
"applicationId": "io.noties.markwon.app",
"variantName": "release",
"elements": [
{
"type": "SINGLE",
"filters": [],
"properties": [],
"versionCode": 1,
"versionName": "1",
"enabled": true,
"outputFile": "markwon-4.4.1-SNAPSHOT-release.apk"
}
]
}

View File

@ -1,8 +1,12 @@
package io.noties.markwon.app.sample.ui
import android.app.AlertDialog
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.os.Parcelable
import android.text.TextUtils
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@ -15,20 +19,26 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import io.noties.adapt.Adapt
import io.noties.adapt.DiffUtilDataSetChanged
import io.noties.adapt.Item
import io.noties.debug.Debug
import io.noties.markwon.Markwon
import io.noties.markwon.app.App
import io.noties.markwon.app.BuildConfig
import io.noties.markwon.app.R
import io.noties.markwon.app.readme.ReadMeActivity
import io.noties.markwon.app.sample.Sample
import io.noties.markwon.app.sample.SampleItem
import io.noties.markwon.app.sample.SampleManager
import io.noties.markwon.app.sample.SampleSearch
import io.noties.markwon.app.sample.ui.adapt.CheckForUpdateItem
import io.noties.markwon.app.sample.ui.adapt.SampleItem
import io.noties.markwon.app.sample.ui.adapt.VersionItem
import io.noties.markwon.app.utils.Cancellable
import io.noties.markwon.app.utils.UpdateUtils
import io.noties.markwon.app.utils.displayName
import io.noties.markwon.app.utils.hidden
import io.noties.markwon.app.utils.onPreDraw
import io.noties.markwon.app.utils.recyclerView
import io.noties.markwon.app.utils.stackTraceString
import io.noties.markwon.app.utils.tagDisplayName
import io.noties.markwon.app.widget.SearchBar
import io.noties.markwon.movement.MovementMethodPlugin
@ -50,6 +60,13 @@ class SampleListFragment : Fragment() {
private var pendingRecyclerScrollPosition: RecyclerScrollPosition? = null
private var cancellable: Cancellable? = null
private var checkForUpdateCancellable: Cancellable? = null
private lateinit var progressBar: View
private val versionItem: VersionItem by lazy(LazyThreadSafetyMode.NONE) {
VersionItem()
}
private val sampleManager: SampleManager
get() = App.sampleManager
@ -73,6 +90,8 @@ class SampleListFragment : Fragment() {
val context = requireContext()
progressBar = view.findViewById(R.id.progress_bar)
val searchBar: SearchBar = view.findViewById(R.id.search_bar)
searchBar.onSearchListener = {
search = it
@ -187,8 +206,10 @@ class SampleListFragment : Fragment() {
}
}
private fun bindSamples(samples: List<Sample>) {
val items = samples.map {
private fun bindSamples(samples: List<Sample>, addVersion: Boolean) {
val items: List<Item<*>> = samples
.map {
SampleItem(
markwon,
it,
@ -197,6 +218,18 @@ class SampleListFragment : Fragment() {
{ sample -> openSample(sample) }
)
}
.let {
if (addVersion) {
val list: List<Item<*>> = it
list.toMutableList().apply {
add(0, CheckForUpdateItem(this@SampleListFragment::checkForUpdate))
add(0, versionItem)
}
} else {
it
}
}
adapt.setItems(items)
val recyclerView = adapt.recyclerView ?: return
@ -218,6 +251,66 @@ class SampleListFragment : Fragment() {
}
}
private fun checkForUpdate() {
val current = checkForUpdateCancellable
if (current != null && !current.isCancelled) {
return
}
progressBar.hidden = false
checkForUpdateCancellable = UpdateUtils.checkForUpdate { result ->
progressBar.post {
processUpdateResult(result)
}
}
}
private fun processUpdateResult(result: UpdateUtils.Result) {
val context = context ?: return
progressBar.hidden = true
val builder = AlertDialog.Builder(context)
when (result) {
is UpdateUtils.Result.UpdateAvailable -> {
val md = """
## Update available
Would you like to download it?
""".trimIndent()
builder.setMessage(markwon.toMarkdown(md))
builder.setNegativeButton(android.R.string.cancel, null)
builder.setPositiveButton("Download") { _, _ ->
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(result.url))
startActivity(Intent.createChooser(intent, null))
}
}
is UpdateUtils.Result.NoUpdate -> {
val md = """
## No update
You are using latest version (${BuildConfig.GIT_SHA})
""".trimIndent()
builder.setMessage(markwon.toMarkdown(md))
builder.setPositiveButton(android.R.string.ok, null)
}
is UpdateUtils.Result.Error -> {
// trimIndent is confused by tabs in stack trace
val md = """
## Error
```
${result.throwable.stackTraceString()}
```
"""
builder.setMessage(markwon.toMarkdown(md))
builder.setPositiveButton(android.R.string.ok, null)
}
}
builder.show()
}
private fun openArtifact(artifact: MarkwonArtifact) {
Debug.i(artifact)
openResultFragment(init(artifact))
@ -262,7 +355,8 @@ class SampleListFragment : Fragment() {
}
cancellable = sampleManager.samples(sampleSearch) {
bindSamples(it)
val addVersion = sampleSearch is SampleSearch.All && TextUtils.isEmpty(sampleSearch.text)
bindSamples(it, addVersion)
}
}

View File

@ -0,0 +1,22 @@
package io.noties.markwon.app.sample.ui.adapt
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import io.noties.adapt.Item
import io.noties.markwon.app.R
class CheckForUpdateItem(private val action: () -> Unit) : Item<CheckForUpdateItem.Holder>(42L) {
override fun createHolder(inflater: LayoutInflater, parent: ViewGroup): Holder {
return Holder(inflater.inflate(R.layout.adapt_check_for_update, parent, false))
}
override fun render(holder: Holder) {
holder.button.setOnClickListener { action() }
}
class Holder(view: View) : Item.Holder(view) {
val button: View = requireView(R.id.button)
}
}

View File

@ -1,4 +1,4 @@
package io.noties.markwon.app.sample
package io.noties.markwon.app.sample.ui.adapt
import android.text.Spanned
import android.view.LayoutInflater
@ -8,6 +8,7 @@ import android.widget.TextView
import io.noties.adapt.Item
import io.noties.markwon.Markwon
import io.noties.markwon.app.R
import io.noties.markwon.app.sample.Sample
import io.noties.markwon.app.utils.displayName
import io.noties.markwon.app.utils.hidden
import io.noties.markwon.app.utils.tagDisplayName

View File

@ -0,0 +1,84 @@
package io.noties.markwon.app.sample.ui.adapt
import android.content.Context
import android.text.Spanned
import android.text.TextPaint
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import io.noties.adapt.Item
import io.noties.markwon.AbstractMarkwonPlugin
import io.noties.markwon.LinkResolver
import io.noties.markwon.Markwon
import io.noties.markwon.MarkwonSpansFactory
import io.noties.markwon.app.BuildConfig
import io.noties.markwon.app.R
import io.noties.markwon.core.CoreProps
import io.noties.markwon.core.MarkwonTheme
import io.noties.markwon.core.spans.LinkSpan
import io.noties.markwon.html.HtmlPlugin
import io.noties.markwon.image.ImagesPlugin
import io.noties.markwon.movement.MovementMethodPlugin
import org.commonmark.node.Link
class VersionItem : Item<VersionItem.Holder>(42L) {
private lateinit var context: Context
private val markwon: Markwon by lazy(LazyThreadSafetyMode.NONE) {
Markwon.builder(context)
.usePlugin(ImagesPlugin.create())
.usePlugin(MovementMethodPlugin.link())
.usePlugin(HtmlPlugin.create())
.usePlugin(object : AbstractMarkwonPlugin() {
override fun configureSpansFactory(builder: MarkwonSpansFactory.Builder) {
builder.setFactory(Link::class.java) { configuration, props ->
LinkSpanNoUnderline(
configuration.theme(),
CoreProps.LINK_DESTINATION.require(props),
configuration.linkResolver()
)
}
}
})
.build()
}
private val text: Spanned by lazy(LazyThreadSafetyMode.NONE) {
val md = """
<a href="${BuildConfig.GIT_REPOSITORY}/blob/master/CHANGELOG.md">
![stable](https://img.shields.io/maven-central/v/io.noties.markwon/core.svg?label=stable)
![snapshot](https://img.shields.io/nexus/s/https/oss.sonatype.org/io.noties.markwon/core.svg?label=snapshot)
![changelog](https://fonts.gstatic.com/s/i/materialicons/open_in_browser/v6/24px.svg?download=true)
</a>
""".trimIndent()
markwon.toMarkdown(md)
}
override fun createHolder(inflater: LayoutInflater, parent: ViewGroup): Holder {
context = parent.context
return Holder(inflater.inflate(R.layout.adapt_version, parent, false))
}
override fun render(holder: Holder) {
markwon.setParsedMarkdown(holder.textView, text)
}
class Holder(view: View) : Item.Holder(view) {
val textView: TextView = requireView(R.id.text_view)
}
class LinkSpanNoUnderline(
theme: MarkwonTheme,
destination: String,
resolver: LinkResolver
) : LinkSpan(theme, destination, resolver) {
override fun updateDrawState(ds: TextPaint) {
super.updateDrawState(ds)
ds.isUnderlineText = false
}
}
}

View File

@ -11,6 +11,7 @@ import java.util.regex.Pattern;
import io.noties.markwon.AbstractMarkwonPlugin;
import io.noties.markwon.Markwon;
import io.noties.markwon.app.BuildConfig;
import io.noties.markwon.app.sample.Tags;
import io.noties.markwon.app.sample.ui.MarkwonTextViewSample;
import io.noties.markwon.inlineparser.InlineProcessor;
@ -78,7 +79,7 @@ class IssueInlineProcessor extends InlineProcessor {
@NonNull
private static String createIssueOrPullRequestLinkDestination(@NonNull String id) {
return "https://github.com/noties/Markwon/issues/" + id;
return BuildConfig.GIT_REPOSITORY + "/issues/" + id;
}
}

View File

@ -13,6 +13,7 @@ import io.noties.markwon.MarkwonConfiguration;
import io.noties.markwon.MarkwonVisitor;
import io.noties.markwon.RenderProps;
import io.noties.markwon.SpannableBuilder;
import io.noties.markwon.app.BuildConfig;
import io.noties.markwon.app.sample.Tags;
import io.noties.markwon.app.sample.ui.MarkwonTextViewSample;
import io.noties.markwon.core.CorePlugin;
@ -87,7 +88,7 @@ class GithubLinkifyRegexTextAddedListener implements CorePlugin.OnTextAddedListe
// issues and pull-requests on github follow the same pattern and we
// cannot know for sure which one it is, but if we use issues for all types,
// github will automatically redirect to pull-request if it's the one which is opened
return "https://github.com/noties/Markwon/issues/" + number;
return BuildConfig.GIT_REPOSITORY + "/issues/" + number;
}
@NonNull

View File

@ -5,6 +5,7 @@ import android.view.ViewGroup
import android.widget.TextView
import android.widget.Toast
import io.noties.markwon.Markwon
import io.noties.markwon.app.BuildConfig
import io.noties.markwon.app.sample.Tags
import io.noties.markwon.app.sample.ui.MarkwonTextViewSample
import io.noties.markwon.image.ImagesPlugin
@ -23,7 +24,7 @@ class ToastDynamicContentSample : MarkwonTextViewSample() {
val md = """
# Head!
![alt](https://github.com/noties/Markwon/raw/master/art/markwon_logo.png)
![alt](${BuildConfig.GIT_REPOSITORY}/raw/master/art/markwon_logo.png)
Do you see an image?
""".trimIndent()

View File

@ -28,6 +28,7 @@ import java.util.List;
import io.noties.markwon.Markwon;
import io.noties.markwon.MarkwonVisitor;
import io.noties.markwon.SpannableBuilder;
import io.noties.markwon.app.BuildConfig;
import io.noties.markwon.app.R;
import io.noties.markwon.app.sample.Tags;
import io.noties.markwon.app.sample.ui.MarkwonSample;
@ -56,7 +57,7 @@ public class HtmlDetailsSample extends MarkwonSample {
@Override
protected int getLayoutResId() {
return R.layout.activity_html_details;
return R.layout.sample_html_details;
}
@Override
@ -83,7 +84,8 @@ public class HtmlDetailsSample extends MarkwonSample {
"* list\n" +
"* with\n" +
"\n\n" +
"![img](https://raw.githubusercontent.com/noties/Markwon/master/art/markwon_logo.png)\n\n" +
"![img](" + BuildConfig.GIT_REPOSITORY + "/raw/master/art/markwon_logo.png)\n\n" +
"" +
" 1. nested\n" +
" 1. items\n" +
"\n" +

View File

@ -2,6 +2,7 @@ package io.noties.markwon.app.samples.movementmethod
import android.text.method.ScrollingMovementMethod
import io.noties.markwon.Markwon
import io.noties.markwon.app.BuildConfig
import io.noties.markwon.app.sample.Tags
import io.noties.markwon.app.sample.ui.MarkwonTextViewSample
import io.noties.markwon.sample.annotations.MarkwonArtifact
@ -22,7 +23,7 @@ class ExplicitMovementMethodSample : MarkwonTextViewSample() {
If `TextView` already has a movement method specified, then `Markwon`
won't be applying a default one. You can specify movement
method via call to `setMovementMethod`. If your movement method can
handle [links](https://github.com/noties/Markwon) then link would be
handle [links](${BuildConfig.GIT_REPOSITORY}) then link would be
_clickable_
""".trimIndent()

View File

@ -1,6 +1,7 @@
package io.noties.markwon.app.samples.movementmethod
import io.noties.markwon.Markwon
import io.noties.markwon.app.BuildConfig
import io.noties.markwon.app.sample.Tags
import io.noties.markwon.app.sample.ui.MarkwonTextViewSample
import io.noties.markwon.sample.annotations.MarkwonArtifact
@ -18,7 +19,7 @@ class ImplicitMovementMethodSample : MarkwonTextViewSample() {
val md = """
# Implicit movement method
By default `Markwon` applies `LinkMovementMethod` if it is missing,
so in order for [links](https://github.com/noties/Markwon) to be clickable
so in order for [links](${BuildConfig.GIT_REPOSITORY}) to be clickable
nothing special should be done
""".trimIndent()

View File

@ -0,0 +1,13 @@
package io.noties.markwon.app.utils
import java.io.PrintWriter
import java.io.StringWriter
object ThrowableUtils
fun Throwable.stackTraceString(): String {
val stringWriter = StringWriter()
val printWriter = PrintWriter(stringWriter)
this.printStackTrace(printWriter)
return stringWriter.toString()
}

View File

@ -0,0 +1,10 @@
package io.noties.markwon.app.utils
@Suppress("unused")
class UncaughtExceptionHandler(private val origin: Thread.UncaughtExceptionHandler?)
: Thread.UncaughtExceptionHandler {
override fun uncaughtException(t: Thread?, e: Throwable?) {
}
}

View File

@ -0,0 +1,63 @@
package io.noties.markwon.app.utils
import io.noties.markwon.app.App
import io.noties.markwon.app.BuildConfig
import okhttp3.Call
import okhttp3.Callback
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import java.io.IOException
object UpdateUtils {
sealed class Result {
class UpdateAvailable(val url: String) : Result()
object NoUpdate : Result()
class Error(val throwable: Throwable) : Result()
}
fun checkForUpdate(updateAction: (Result) -> Unit): Cancellable {
var action: ((Result) -> Unit)? = updateAction
val future = App.executorService
.submit {
val url = "${BuildConfig.GIT_REPOSITORY}/raw/sample-store/version"
val request = Request.Builder()
.get()
.url(url)
.build()
OkHttpClient().newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
action?.invoke(Result.Error(e))
}
override fun onResponse(call: Call, response: Response) {
try {
val revision = response.body()?.string()
val hasUpdate = revision != null && BuildConfig.GIT_SHA != revision
if (hasUpdate) {
action?.invoke(Result.UpdateAvailable(apkUrl))
} else {
action?.invoke(Result.NoUpdate)
}
} catch (e: IOException) {
action?.invoke(Result.Error(e))
}
}
})
}
return object : Cancellable {
override val isCancelled: Boolean
get() = future.isDone
override fun cancel() {
action = null
future.cancel(true)
}
}
}
private const val apkUrl = "${BuildConfig.GIT_REPOSITORY}/raw/sample-store/markwon-debug.apk"
}

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="#FFFFFF"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M19,4L5,4c-1.11,0 -2,0.9 -2,2v12c0,1.1 0.89,2 2,2h4v-2L5,18L5,8h14v10h-4v2h4c1.1,0 2,-0.9 2,-2L21,6c0,-1.1 -0.89,-2 -2,-2zM12,10l-4,4h3v6h2v-6h3l-4,-4z" />
</vector>

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingLeft="@dimen/content_padding"
android:paddingRight="@dimen/content_padding">
<Button
android:id="@+id/button"
style="@android:style/Widget.Material.Button.Borderless.Colored"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/check_for_update" />
</FrameLayout>

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/text_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingLeft="@dimen/content_padding_double"
android:paddingTop="@dimen/content_padding"
android:paddingRight="@dimen/content_padding_double"
android:paddingBottom="@dimen/content_padding"
android:textAppearance="?android:attr/textAppearanceMedium"
tools:text="Badges here" />

View File

@ -48,6 +48,17 @@
</LinearLayout>
<ProgressBar
android:id="@+id/progress_bar"
style="@android:style/Widget.Material.ProgressBar.Horizontal"
android:layout_width="match_parent"
android:layout_height="16dip"
android:layout_marginTop="-8dip"
android:layout_marginBottom="-8dip"
android:indeterminate="true"
android:visibility="gone"
tools:visibility="visible" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"

View File

@ -5,6 +5,8 @@
<string name="tab_bar_preview">Preview</string>
<string name="tab_bar_code">Code</string>
<string name="check_for_update">Check for update</string>
<string name="lorem"><![CDATA[
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis rutrum orci at aliquet dapibus. Quisque laoreet fermentum bibendum. Suspendisse euismod nisl vel sapien viverra faucibus. Nulla vel neque volutpat, egestas dui ac, consequat elit. Donec et interdum massa. Quisque porta ornare posuere. Nam at ante a felis facilisis tempus eu et erat. Curabitur auctor mauris eget purus iaculis vulputate.

Binary file not shown.

View File

@ -29,7 +29,6 @@ dependencies {
testImplementation deps['commons-io']
deps['test'].with {
testImplementation it['junit']
testImplementation it['robolectric']
testImplementation it['mockito']