diff --git a/markwon-core/src/main/java/ru/noties/markwon/AbstractMarkwonPlugin.java b/markwon-core/src/main/java/ru/noties/markwon/AbstractMarkwonPlugin.java index 5492d4d0..3bf54029 100644 --- a/markwon-core/src/main/java/ru/noties/markwon/AbstractMarkwonPlugin.java +++ b/markwon-core/src/main/java/ru/noties/markwon/AbstractMarkwonPlugin.java @@ -22,6 +22,11 @@ import ru.noties.markwon.priority.Priority; */ public abstract class AbstractMarkwonPlugin implements MarkwonPlugin { + @Override + public void configure(@NonNull Registry registry) { + + } + @Override public void configureParser(@NonNull Parser.Builder builder) { @@ -32,11 +37,6 @@ public abstract class AbstractMarkwonPlugin implements MarkwonPlugin { } - @Override - public void configureImages(@NonNull AsyncDrawableLoader.Builder builder) { - - } - @Override public void configureConfiguration(@NonNull MarkwonConfiguration.Builder builder) { @@ -57,13 +57,6 @@ public abstract class AbstractMarkwonPlugin implements MarkwonPlugin { } - @NonNull - @Override - public Priority priority() { - // by default all come after CorePlugin - return Priority.after(CorePlugin.class); - } - @NonNull @Override public String processMarkdown(@NonNull String markdown) { diff --git a/markwon-core/src/main/java/ru/noties/markwon/Markwon.java b/markwon-core/src/main/java/ru/noties/markwon/Markwon.java index 0ac110ff..17f4af24 100644 --- a/markwon-core/src/main/java/ru/noties/markwon/Markwon.java +++ b/markwon-core/src/main/java/ru/noties/markwon/Markwon.java @@ -37,13 +37,26 @@ public abstract class Markwon { } /** - * Factory method to obtain an instance of {@link Builder}. + * Factory method to obtain an instance of {@link Builder} with {@link CorePlugin} added. * * @see Builder + * @see #builderNoCore(Context) * @since 3.0.0 */ @NonNull public static Builder builder(@NonNull Context context) { + return new MarkwonBuilderImpl(context) + // @since 4.0.0-SNAPSHOT add CorePlugin + .usePlugin(CorePlugin.create()); + } + + /** + * Factory method to obtain an instance of {@link Builder} without {@link CorePlugin}. + * + * @since 4.0.0-SNAPSHOT + */ + @NonNull + public static Builder builderNoCore(@NonNull Context context) { return new MarkwonBuilderImpl(context); } diff --git a/markwon-core/src/main/java/ru/noties/markwon/MarkwonBuilderImpl.java b/markwon-core/src/main/java/ru/noties/markwon/MarkwonBuilderImpl.java index a390c46e..b650ff1b 100644 --- a/markwon-core/src/main/java/ru/noties/markwon/MarkwonBuilderImpl.java +++ b/markwon-core/src/main/java/ru/noties/markwon/MarkwonBuilderImpl.java @@ -2,6 +2,7 @@ package ru.noties.markwon; import android.content.Context; import android.support.annotation.NonNull; +import android.support.annotation.Nullable; import android.support.annotation.VisibleForTesting; import android.widget.TextView; @@ -9,19 +10,17 @@ import org.commonmark.parser.Parser; import java.util.ArrayList; import java.util.Collections; +import java.util.HashSet; import java.util.Iterator; import java.util.List; +import java.util.Set; -import ru.noties.markwon.core.CorePlugin; import ru.noties.markwon.core.MarkwonTheme; import ru.noties.markwon.html.MarkwonHtmlRenderer; -import ru.noties.markwon.image.AsyncDrawableLoader; -import ru.noties.markwon.priority.PriorityProcessor; /** * @since 3.0.0 */ -@SuppressWarnings("WeakerAccess") class MarkwonBuilderImpl implements Markwon.Builder { private final Context context; @@ -30,8 +29,6 @@ class MarkwonBuilderImpl implements Markwon.Builder { private TextView.BufferType bufferType = TextView.BufferType.SPANNABLE; - private PriorityProcessor priorityProcessor; - MarkwonBuilderImpl(@NonNull Context context) { this.context = context; } @@ -69,13 +66,6 @@ class MarkwonBuilderImpl implements Markwon.Builder { return this; } - @SuppressWarnings("UnusedReturnValue") - @NonNull - public MarkwonBuilderImpl priorityProcessor(@NonNull PriorityProcessor priorityProcessor) { - this.priorityProcessor = priorityProcessor; - return this; - } - @NonNull @Override public Markwon build() { @@ -85,21 +75,12 @@ class MarkwonBuilderImpl implements Markwon.Builder { "method to add them"); } - // this class will sort plugins to match a priority/dependency graph that we have - PriorityProcessor priorityProcessor = this.priorityProcessor; - if (priorityProcessor == null) { - // strictly speaking we do not need updating this field - // as we are not building this class to be reused between multiple `build` calls - priorityProcessor = this.priorityProcessor = PriorityProcessor.create(); - } - // please note that this method must not modify supplied collection // if nothing should be done -> the same collection can be returned - final List plugins = preparePlugins(priorityProcessor, this.plugins); + final List plugins = preparePlugins(this.plugins); final Parser.Builder parserBuilder = new Parser.Builder(); final MarkwonTheme.Builder themeBuilder = MarkwonTheme.builderWithDefaults(context); - final AsyncDrawableLoader.Builder asyncDrawableLoaderBuilder = new AsyncDrawableLoader.Builder(); final MarkwonConfiguration.Builder configurationBuilder = new MarkwonConfiguration.Builder(); final MarkwonVisitor.Builder visitorBuilder = new MarkwonVisitorImpl.BuilderImpl(); final MarkwonSpansFactory.Builder spanFactoryBuilder = new MarkwonSpansFactoryImpl.BuilderImpl(); @@ -108,7 +89,6 @@ class MarkwonBuilderImpl implements Markwon.Builder { for (MarkwonPlugin plugin : plugins) { plugin.configureParser(parserBuilder); plugin.configureTheme(themeBuilder); - plugin.configureImages(asyncDrawableLoaderBuilder); plugin.configureConfiguration(configurationBuilder); plugin.configureVisitor(visitorBuilder); plugin.configureSpansFactory(spanFactoryBuilder); @@ -117,7 +97,6 @@ class MarkwonBuilderImpl implements Markwon.Builder { final MarkwonConfiguration configuration = configurationBuilder.build( themeBuilder.build(), - asyncDrawableLoaderBuilder.build(), htmlRendererBuilder.build(), spanFactoryBuilder.build()); @@ -133,62 +112,107 @@ class MarkwonBuilderImpl implements Markwon.Builder { @VisibleForTesting @NonNull - static List preparePlugins( - @NonNull PriorityProcessor priorityProcessor, - @NonNull List plugins) { - - // with this method we will ensure that CorePlugin is added IF and ONLY IF - // there are plugins that depend on it. If CorePlugin is added, or there are - // no plugins that require it, CorePlugin won't be added - final List out = ensureImplicitCoreIfHasDependents(plugins); - - return priorityProcessor.process(out); + static List preparePlugins(@NonNull List plugins) { + return new RegistryImpl(plugins).process(); } - // this method will _implicitly_ add CorePlugin if there is at least one plugin - // that depends on CorePlugin - @VisibleForTesting - @NonNull - static List ensureImplicitCoreIfHasDependents(@NonNull List plugins) { - // loop over plugins -> if CorePlugin is found -> break; - // iterate over all plugins and check if CorePlugin is requested + // @since 4.0.0-SNAPSHOT + private static class RegistryImpl implements MarkwonPlugin.Registry { - boolean hasCore = false; - boolean hasCoreDependents = false; + private final List origin; + private final List plugins; + private final Set pending; - for (MarkwonPlugin plugin : plugins) { + RegistryImpl(@NonNull List origin) { + this.origin = origin; + this.plugins = new ArrayList<>(origin.size()); + this.pending = new HashSet<>(3); + } - // here we do not check for exact match (a user could've subclasses CorePlugin - // and supplied it. In this case we DO NOT implicitly add CorePlugin - // - // if core is present already we do not need to iterate anymore -> as nothing - // will be changed (and we actually do not care if there are any dependents of Core - // as it's present anyway) - if (CorePlugin.class.isAssignableFrom(plugin.getClass())) { - hasCore = true; - break; + @NonNull + @Override + public

P require(@NonNull Class

plugin) { + return get(plugin); + } + + @Override + public

void require( + @NonNull Class

plugin, + @NonNull MarkwonPlugin.Action action) { + action.apply(get(plugin)); + } + + @NonNull + List process() { + for (MarkwonPlugin plugin : origin) { + configure(plugin); } + return plugins; + } - // if plugin has CorePlugin in dependencies -> mark for addition - if (!hasCoreDependents) { - // here we check for direct CorePlugin, if it's not CorePlugin (exact, not a subclass - // or something -> ignore) - if (plugin.priority().after().contains(CorePlugin.class)) { - hasCoreDependents = true; + private void configure(@NonNull MarkwonPlugin plugin) { + + // important -> check if it's in plugins + // if it is -> no need to configure (already configured) + + if (!plugins.contains(plugin)) { + + if (pending.contains(plugin)) { + throw new IllegalStateException("Cyclic dependency chain found: " + pending); + } + + // start tracking plugins that are pending for configuration + pending.add(plugin); + + plugin.configure(this); + + // stop pending tracking + pending.remove(plugin); + + // check again if it's included (a child might've configured it already) + // add to out-collection if not already present + // this is a bit different from `find` method as it does check for exact instance + // and not a sub-type + if (!plugins.contains(plugin)) { + plugins.add(plugin); } } } - // important thing here is to check if corePlugin is added - // add it _only_ if it's not present - if (hasCoreDependents && !hasCore) { - final List out = new ArrayList<>(plugins.size() + 1); - // add default instance of CorePlugin - out.add(CorePlugin.create()); - out.addAll(plugins); - return out; + @NonNull + private

P get(@NonNull Class

type) { + + // check if present already in plugins + // find in origin, if not found -> throw, else add to out-plugins + + P plugin = find(plugins, type); + + if (plugin == null) { + + plugin = find(origin, type); + + if (plugin == null) { + throw new IllegalStateException("Requested plugin is not added: " + + "" + type.getName() + ", plugins: " + origin); + } + + configure(plugin); + } + + return plugin; } - return plugins; + @Nullable + private static

P find( + @NonNull List plugins, + @NonNull Class

type) { + for (MarkwonPlugin plugin : plugins) { + if (type.isAssignableFrom(plugin.getClass())) { + //noinspection unchecked + return (P) plugin; + } + } + return null; + } } } diff --git a/markwon-core/src/main/java/ru/noties/markwon/MarkwonConfiguration.java b/markwon-core/src/main/java/ru/noties/markwon/MarkwonConfiguration.java index af481ee8..af65fdb4 100644 --- a/markwon-core/src/main/java/ru/noties/markwon/MarkwonConfiguration.java +++ b/markwon-core/src/main/java/ru/noties/markwon/MarkwonConfiguration.java @@ -113,6 +113,15 @@ public class MarkwonConfiguration { Builder() { } + /** + * @since 4.0.0-SNAPSHOT + */ + @NonNull + public Builder asyncDrawableLoader(@NonNull AsyncDrawableLoader asyncDrawableLoader) { + this.asyncDrawableLoader = asyncDrawableLoader; + return this; + } + @NonNull public Builder syntaxHighlight(@NonNull SyntaxHighlight syntaxHighlight) { this.syntaxHighlight = syntaxHighlight; @@ -149,15 +158,18 @@ public class MarkwonConfiguration { @NonNull public MarkwonConfiguration build( @NonNull MarkwonTheme theme, - @NonNull AsyncDrawableLoader asyncDrawableLoader, @NonNull MarkwonHtmlRenderer htmlRenderer, @NonNull MarkwonSpansFactory spansFactory) { this.theme = theme; - this.asyncDrawableLoader = asyncDrawableLoader; this.htmlRenderer = htmlRenderer; this.spansFactory = spansFactory; + // @since 4.0.0-SNAPSHOT + if (asyncDrawableLoader == null) { + asyncDrawableLoader = AsyncDrawableLoader.noOp(); + } + if (syntaxHighlight == null) { syntaxHighlight = new SyntaxHighlightNoOp(); } diff --git a/markwon-core/src/main/java/ru/noties/markwon/MarkwonPlugin.java b/markwon-core/src/main/java/ru/noties/markwon/MarkwonPlugin.java index 0804848b..8bc4dc8d 100644 --- a/markwon-core/src/main/java/ru/noties/markwon/MarkwonPlugin.java +++ b/markwon-core/src/main/java/ru/noties/markwon/MarkwonPlugin.java @@ -9,10 +9,6 @@ import org.commonmark.parser.Parser; import ru.noties.markwon.core.MarkwonTheme; import ru.noties.markwon.html.MarkwonHtmlRenderer; -import ru.noties.markwon.image.AsyncDrawableLoader; -import ru.noties.markwon.image.MediaDecoder; -import ru.noties.markwon.image.SchemeHandler; -import ru.noties.markwon.priority.Priority; /** * Class represents a plugin (extension) to Markwon to configure how parsing and rendering @@ -25,6 +21,35 @@ import ru.noties.markwon.priority.Priority; */ public interface MarkwonPlugin { + /** + * @see Registry#require(Class, Action) + * @since 4.0.0-SNAPSHOT + */ + interface Action

{ + void apply(@NonNull P p); + } + + /** + * @see #configure(Registry) + * @since 4.0.0-SNAPSHOT + */ + interface Registry { + + @NonNull +

P require(@NonNull Class

plugin); + +

void require( + @NonNull Class

plugin, + @NonNull Action action); + } + + /** + * This method will be called before any other during {@link Markwon} instance construction. + * + * @since 4.0.0-SNAPSHOT + */ + void configure(@NonNull Registry registry); + /** * Method to configure org.commonmark.parser.Parser (for example register custom * extension, etc). @@ -39,17 +64,6 @@ public interface MarkwonPlugin { */ void configureTheme(@NonNull MarkwonTheme.Builder builder); - /** - * Configure image loading functionality. For example add new content-types - * {@link AsyncDrawableLoader.Builder#addMediaDecoder(String, MediaDecoder)}, a transport - * layer (network, file, etc) {@link AsyncDrawableLoader.Builder#addSchemeHandler(String, SchemeHandler)} - * or modify existing properties. - * - * @see AsyncDrawableLoader - * @see AsyncDrawableLoader.Builder - */ - void configureImages(@NonNull AsyncDrawableLoader.Builder builder); - /** * Configure {@link MarkwonConfiguration} * @@ -82,9 +96,6 @@ public interface MarkwonPlugin { */ void configureHtmlRenderer(@NonNull MarkwonHtmlRenderer.Builder builder); - @NonNull - Priority priority(); - /** * Process input markdown and return new string to be used in parsing stage further. * Can be described as pre-processing of markdown String. diff --git a/markwon-core/src/main/java/ru/noties/markwon/core/CorePlugin.java b/markwon-core/src/main/java/ru/noties/markwon/core/CorePlugin.java index 77fa3653..39436839 100644 --- a/markwon-core/src/main/java/ru/noties/markwon/core/CorePlugin.java +++ b/markwon-core/src/main/java/ru/noties/markwon/core/CorePlugin.java @@ -95,12 +95,6 @@ public class CorePlugin extends AbstractMarkwonPlugin { .setFactory(ThematicBreak.class, new ThematicBreakSpanFactory()); } - @NonNull - @Override - public Priority priority() { - return Priority.none(); - } - @Override public void beforeSetText(@NonNull TextView textView, @NonNull Spanned markdown) { OrderedListItemSpan.measure(textView, markdown); diff --git a/markwon-core/src/main/java/ru/noties/markwon/image/AsyncDrawableLoader.java b/markwon-core/src/main/java/ru/noties/markwon/image/AsyncDrawableLoader.java index 3eba33ca..7e297bf3 100644 --- a/markwon-core/src/main/java/ru/noties/markwon/image/AsyncDrawableLoader.java +++ b/markwon-core/src/main/java/ru/noties/markwon/image/AsyncDrawableLoader.java @@ -13,22 +13,6 @@ import java.util.concurrent.Executors; public abstract class AsyncDrawableLoader { - /** - * @since 3.0.0 - */ - public interface DrawableProvider { - @Nullable - Drawable provide(); - } - - /** - * @since 3.0.0 - */ - @NonNull - public static Builder builder() { - return new Builder(); - } - /** * @since 3.0.0 */ @@ -71,123 +55,4 @@ public abstract class AsyncDrawableLoader { @Nullable public abstract Drawable placeholder(); - - public static class Builder { - - ExecutorService executorService; - final Map schemeHandlers = new HashMap<>(3); - final Map mediaDecoders = new HashMap<>(3); - MediaDecoder defaultMediaDecoder; - DrawableProvider placeholderDrawableProvider; - DrawableProvider errorDrawableProvider; - - AsyncDrawableLoader implementation; - - @NonNull - public Builder executorService(@NonNull ExecutorService executorService) { - this.executorService = executorService; - return this; - } - - @NonNull - public Builder addSchemeHandler(@NonNull String scheme, @NonNull SchemeHandler schemeHandler) { - schemeHandlers.put(scheme, schemeHandler); - return this; - } - - @NonNull - public Builder addSchemeHandler(@NonNull Collection schemes, @NonNull SchemeHandler schemeHandler) { - for (String scheme : schemes) { - schemeHandlers.put(scheme, schemeHandler); - } - return this; - } - - @NonNull - public Builder addMediaDecoder(@NonNull String contentType, @NonNull MediaDecoder mediaDecoder) { - mediaDecoders.put(contentType, mediaDecoder); - return this; - } - - @NonNull - public Builder addMediaDecoder(@NonNull Collection contentTypes, @NonNull MediaDecoder mediaDecoder) { - for (String contentType : contentTypes) { - mediaDecoders.put(contentType, mediaDecoder); - } - return this; - } - - @NonNull - public Builder removeSchemeHandler(@NonNull String scheme) { - schemeHandlers.remove(scheme); - return this; - } - - @NonNull - public Builder removeMediaDecoder(@NonNull String contentType) { - mediaDecoders.remove(contentType); - return this; - } - - @NonNull - public Builder defaultMediaDecoder(@Nullable MediaDecoder mediaDecoder) { - this.defaultMediaDecoder = mediaDecoder; - return this; - } - - /** - * @since 3.0.0 - */ - @NonNull - public Builder placeholderDrawableProvider(@NonNull DrawableProvider placeholderDrawableProvider) { - this.placeholderDrawableProvider = placeholderDrawableProvider; - return this; - } - - /** - * @since 3.0.0 - */ - @NonNull - public Builder errorDrawableProvider(@NonNull DrawableProvider errorDrawableProvider) { - this.errorDrawableProvider = errorDrawableProvider; - return this; - } - - /** - * Please note that if implementation is supplied, all configuration properties - * (scheme-handlers, media-decoders, placeholder, etc) of this builder instance - * will be ignored. - * - * @param implementation {@link AsyncDrawableLoader} implementation to be used. - * @since 3.0.1 - */ - @NonNull - public Builder implementation(@NonNull AsyncDrawableLoader implementation) { - this.implementation = implementation; - return this; - } - - @NonNull - public AsyncDrawableLoader build() { - - // NB, all other configuration properties will be ignored if - // implementation is specified - if (implementation != null) { - return implementation; - } - - // if we have no schemeHandlers -> we cannot show anything - // OR if we have no media decoders - if (schemeHandlers.size() == 0 - || (mediaDecoders.size() == 0 && defaultMediaDecoder == null)) { - return new AsyncDrawableLoaderNoOp(); - } - - if (executorService == null) { - executorService = Executors.newCachedThreadPool(); - } - - return new AsyncDrawableLoaderImpl(this); - } - } } diff --git a/markwon-core/src/main/java/ru/noties/markwon/image/ImageItem.java b/markwon-core/src/main/java/ru/noties/markwon/image/ImageItem.java index 2dc4b729..a6e9f72e 100644 --- a/markwon-core/src/main/java/ru/noties/markwon/image/ImageItem.java +++ b/markwon-core/src/main/java/ru/noties/markwon/image/ImageItem.java @@ -1,5 +1,7 @@ package ru.noties.markwon.image; +import android.graphics.drawable.Drawable; +import android.support.annotation.NonNull; import android.support.annotation.Nullable; import java.io.InputStream; @@ -7,25 +9,62 @@ import java.io.InputStream; /** * @since 2.0.0 */ -public class ImageItem { +public abstract class ImageItem { - private final String contentType; - private final InputStream inputStream; + /** + * Create an {@link ImageItem} with result, so no further decoding is required. + * + * @since 4.0.0-SNAPSHOT + */ + @NonNull + public static ImageItem withResult(@Nullable Drawable drawable) { + return new WithResult(drawable); + } - public ImageItem( + @NonNull + public static ImageItem withDecodingNeeded( @Nullable String contentType, @Nullable InputStream inputStream) { - this.contentType = contentType; - this.inputStream = inputStream; + return new WithDecodingNeeded(contentType, inputStream); } - @Nullable - public String contentType() { - return contentType; + private ImageItem() { } - @Nullable - public InputStream inputStream() { - return inputStream; + public static class WithResult extends ImageItem { + + private final Drawable result; + + WithResult(@Nullable Drawable drawable) { + result = drawable; + } + + @Nullable + public Drawable result() { + return result; + } + } + + public static class WithDecodingNeeded extends ImageItem { + + private final String contentType; + private final InputStream inputStream; + + WithDecodingNeeded( + @Nullable String contentType, + @Nullable InputStream inputStream) { + this.contentType = contentType; + this.inputStream = inputStream; + } + + @Nullable + public String contentType() { + return contentType; + } + + @Nullable + public InputStream inputStream() { + return inputStream; + } } } diff --git a/markwon-core/src/main/java/ru/noties/markwon/priority/Priority.java b/markwon-core/src/main/java/ru/noties/markwon/priority/Priority.java index 5582ff72..e10faf2a 100644 --- a/markwon-core/src/main/java/ru/noties/markwon/priority/Priority.java +++ b/markwon-core/src/main/java/ru/noties/markwon/priority/Priority.java @@ -17,6 +17,7 @@ import ru.noties.markwon.MarkwonPlugin; * @see MarkwonPlugin#priority() * @since 3.0.0 */ +@Deprecated public abstract class Priority { @NonNull diff --git a/markwon-image/build.gradle b/markwon-image/build.gradle new file mode 100644 index 00000000..86fac312 --- /dev/null +++ b/markwon-image/build.gradle @@ -0,0 +1,27 @@ +apply plugin: 'com.android.library' + +android { + + compileSdkVersion config['compile-sdk'] + buildToolsVersion config['build-tools'] + + defaultConfig { + minSdkVersion config['min-sdk'] + targetSdkVersion config['target-sdk'] + versionCode 1 + versionName version + } +} + +dependencies { + + api project(':markwon-core') + + deps.with { + compileOnly it['android-gif'] + compileOnly it['android-svg'] + compileOnly it['okhttp'] + } +} + +registerArtifact(this) \ No newline at end of file diff --git a/markwon-image/gradle.properties b/markwon-image/gradle.properties new file mode 100644 index 00000000..a845eebf --- /dev/null +++ b/markwon-image/gradle.properties @@ -0,0 +1,4 @@ +POM_NAME=Image +POM_ARTIFACT_ID=image +POM_DESCRIPTION=Markwon image loading module (with GIF and SVG support) +POM_PACKAGING=aar \ No newline at end of file diff --git a/markwon-image/src/main/AndroidManifest.xml b/markwon-image/src/main/AndroidManifest.xml new file mode 100644 index 00000000..e8b1253d --- /dev/null +++ b/markwon-image/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/markwon-image/src/main/java/ru/noties/markwon/image/AsyncDrawableLoaderBuilder.java b/markwon-image/src/main/java/ru/noties/markwon/image/AsyncDrawableLoaderBuilder.java new file mode 100644 index 00000000..47c91146 --- /dev/null +++ b/markwon-image/src/main/java/ru/noties/markwon/image/AsyncDrawableLoaderBuilder.java @@ -0,0 +1,110 @@ +package ru.noties.markwon.image; + +import android.graphics.drawable.Drawable; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public class AsyncDrawableLoaderBuilder { + + ExecutorService executorService; + final Map schemeHandlers = new HashMap<>(3); + final Map mediaDecoders = new HashMap<>(3); + MediaDecoder defaultMediaDecoder; + DrawableProvider placeholderDrawableProvider; + DrawableProvider errorDrawableProvider; + + @NonNull + public AsyncDrawableLoaderBuilder executorService(@NonNull ExecutorService executorService) { + this.executorService = executorService; + return this; + } + + @NonNull + public AsyncDrawableLoaderBuilder addSchemeHandler(@NonNull String scheme, @NonNull SchemeHandler schemeHandler) { + schemeHandlers.put(scheme, schemeHandler); + return this; + } + + @NonNull + public AsyncDrawableLoaderBuilder addSchemeHandler(@NonNull Collection schemes, @NonNull SchemeHandler schemeHandler) { + for (String scheme : schemes) { + schemeHandlers.put(scheme, schemeHandler); + } + return this; + } + + @NonNull + public AsyncDrawableLoaderBuilder addMediaDecoder(@NonNull String contentType, @NonNull MediaDecoder mediaDecoder) { + mediaDecoders.put(contentType, mediaDecoder); + return this; + } + + @NonNull + public AsyncDrawableLoaderBuilder addMediaDecoder(@NonNull Collection contentTypes, @NonNull MediaDecoder mediaDecoder) { + for (String contentType : contentTypes) { + mediaDecoders.put(contentType, mediaDecoder); + } + return this; + } + + @NonNull + public AsyncDrawableLoaderBuilder removeSchemeHandler(@NonNull String scheme) { + schemeHandlers.remove(scheme); + return this; + } + + @NonNull + public AsyncDrawableLoaderBuilder removeMediaDecoder(@NonNull String contentType) { + mediaDecoders.remove(contentType); + return this; + } + + @NonNull + public AsyncDrawableLoaderBuilder defaultMediaDecoder(@Nullable MediaDecoder mediaDecoder) { + this.defaultMediaDecoder = mediaDecoder; + return this; + } + + /** + * @since 3.0.0 + */ + @NonNull + public AsyncDrawableLoaderBuilder placeholderDrawableProvider(@NonNull DrawableProvider placeholderDrawableProvider) { + this.placeholderDrawableProvider = placeholderDrawableProvider; + return this; + } + + /** + * @since 3.0.0 + */ + @NonNull + public AsyncDrawableLoaderBuilder errorDrawableProvider(@NonNull DrawableProvider errorDrawableProvider) { + this.errorDrawableProvider = errorDrawableProvider; + return this; + } + + + @NonNull + public AsyncDrawableLoader build() { + + // if we have no schemeHandlers -> we cannot show anything + // OR if we have no media decoders + if (schemeHandlers.size() == 0 + || (mediaDecoders.size() == 0 && defaultMediaDecoder == null)) { + return new AsyncDrawableLoaderNoOp(); + } + + if (executorService == null) { + executorService = Executors.newCachedThreadPool(); + } + + return new AsyncDrawableLoaderImpl(this); + } + +} diff --git a/markwon-image/src/main/java/ru/noties/markwon/image/AsyncDrawableLoaderImpl.java b/markwon-image/src/main/java/ru/noties/markwon/image/AsyncDrawableLoaderImpl.java new file mode 100644 index 00000000..711508c2 --- /dev/null +++ b/markwon-image/src/main/java/ru/noties/markwon/image/AsyncDrawableLoaderImpl.java @@ -0,0 +1,290 @@ +package ru.noties.markwon.image; + +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.os.Handler; +import android.os.Looper; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import java.io.IOException; +import java.io.InputStream; +import java.lang.ref.WeakReference; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; + +class AsyncDrawableLoaderImpl extends AsyncDrawableLoader { + + private final ExecutorService executorService; + private final Map schemeHandlers; + private final Map mediaDecoders; + private final MediaDecoder defaultMediaDecoder; + private final DrawableProvider placeholderDrawableProvider; + private final DrawableProvider errorDrawableProvider; + + private final Handler mainThread; + + // @since 3.1.0-SNAPSHOT use a hash-map with a weak AsyncDrawable as key for multiple requests + // for the same destination + private final Map, Future> requests = new HashMap<>(2); + + AsyncDrawableLoaderImpl(@NonNull Builder builder) { + this.executorService = builder.executorService; + this.schemeHandlers = builder.schemeHandlers; + this.mediaDecoders = builder.mediaDecoders; + this.defaultMediaDecoder = builder.defaultMediaDecoder; + this.placeholderDrawableProvider = builder.placeholderDrawableProvider; + this.errorDrawableProvider = builder.errorDrawableProvider; + this.mainThread = new Handler(Looper.getMainLooper()); + } + + @Override + public void load(@NonNull final AsyncDrawable drawable) { + + // primitive synchronization via main-thread + if (!isMainThread()) { + mainThread.post(new Runnable() { + @Override + public void run() { + load(drawable); + } + }); + return; + } + + // okay, if by some chance requested drawable already has a future associated -> no-op + // as AsyncDrawable cannot change `destination` (immutable field) + // @since 3.1.0-SNAPSHOT + if (hasTaskAssociated(drawable)) { + return; + } + + final WeakReference reference = new WeakReference<>(drawable); + requests.put(reference, execute(drawable.getDestination(), reference)); + } + + @Override + public void cancel(@NonNull final AsyncDrawable drawable) { + + if (!isMainThread()) { + mainThread.post(new Runnable() { + @Override + public void run() { + cancel(drawable); + } + }); + return; + } + + final Iterator, Future>> iterator = + requests.entrySet().iterator(); + + AsyncDrawable key; + Map.Entry, Future> entry; + + while (iterator.hasNext()) { + + entry = iterator.next(); + key = entry.getKey().get(); + + // if key is null or it contains requested AsyncDrawable -> cancel + if (shouldCleanUp(key) || key == drawable) { + entry.getValue().cancel(true); + iterator.remove(); + } + } + } + + private boolean hasTaskAssociated(@NonNull AsyncDrawable drawable) { + + final Iterator, Future>> iterator = + requests.entrySet().iterator(); + + boolean result = false; + + AsyncDrawable key; + Map.Entry, Future> entry; + + while (iterator.hasNext()) { + + entry = iterator.next(); + key = entry.getKey().get(); + + // clean-up + if (shouldCleanUp(key)) { + entry.getValue().cancel(true); + iterator.remove(); + } else if (key == drawable) { + result = true; + // do not break, let iteration continue to possibly clean-up the rest references + } + } + + return result; + } + + private void cleanUp() { + + final Iterator, Future>> iterator = + requests.entrySet().iterator(); + + AsyncDrawable key; + Map.Entry, Future> entry; + + while (iterator.hasNext()) { + + entry = iterator.next(); + key = entry.getKey().get(); + + // clean-up of already referenced or detached drawables + if (shouldCleanUp(key)) { + entry.getValue().cancel(true); + iterator.remove(); + } + } + } + +// @Override +// public void load(@NonNull String destination, @NonNull AsyncDrawable drawable) { +// +// // todo: we cannot reliably identify request by the destination, as if +// // markdown input has multiple images with the same destination as source +// // we will be tracking only one of them (the one appears the last). We should +// // move to AsyncDrawable based identification. This method also _maybe_ +// // should include the ImageSize (comment @since 3.1.0-SNAPSHOT) +// +// requests.put(destination, execute(destination, drawable)); +// } +// +// @Override +// public void cancel(@NonNull String destination) { +// +// // todo: as we are moving away from a single request for a destination, +// // we should re-evaluate this cancellation logic, as if there are multiple images +// // in markdown input all of them will be cancelled (won't delivered), even if +// // only a single drawable is detached. Cancellation must also take +// // the AsyncDrawable argument (comment @since 3.1.0-SNAPSHOT) +// +// // +// final Future request = requests.remove(destination); +// if (request != null) { +// request.cancel(true); +// } +// } + + @Nullable + @Override + public Drawable placeholder() { + return placeholderDrawableProvider != null + ? placeholderDrawableProvider.provide() + : null; + } + + private Future execute(@NonNull final String destination, @NonNull final WeakReference reference) { + + // todo: error handing (simply applying errorDrawable is not a good solution + // as reason for an error is unclear (no scheme handler, no input data, error decoding, etc) + + // todo: more efficient ImageMediaDecoder... BitmapFactory.decodeStream is a bit not optimal + // for big images for sure. We _could_ introduce internal Drawable that will check for + // image bounds (but we will need to cache inputStream in order to inspect and optimize + // input image...) + + return executorService.submit(new Runnable() { + @Override + public void run() { + + final ImageItem item; + + final Uri uri = Uri.parse(destination); + + final SchemeHandler schemeHandler = schemeHandlers.get(uri.getScheme()); + + if (schemeHandler != null) { + item = schemeHandler.handle(destination, uri); + } else { + item = null; + } + + final InputStream inputStream = item != null + ? item.inputStream() + : null; + + Drawable result = null; + + if (inputStream != null) { + try { + + MediaDecoder mediaDecoder = mediaDecoders.get(item.contentType()); + if (mediaDecoder == null) { + mediaDecoder = defaultMediaDecoder; + } + + if (mediaDecoder != null) { + result = mediaDecoder.decode(inputStream); + } + + } finally { + try { + inputStream.close(); + } catch (IOException e) { + // ignored + } + } + } + + // if result is null, we assume it's an error + if (result == null) { + result = errorDrawableProvider != null + ? errorDrawableProvider.provide() + : null; + } + + final Drawable out = result; + + mainThread.post(new Runnable() { + @Override + public void run() { + + if (out != null) { + + // this doesn't work with markdown input with multiple images with the + // same source (comment @since 3.1.0-SNAPSHOT) +// final boolean canDeliver = requests.remove(destination) != null; +// if (canDeliver) { +// final AsyncDrawable asyncDrawable = reference.get(); +// if (asyncDrawable != null && asyncDrawable.isAttached()) { +// asyncDrawable.setResult(out); +// } +// } + + // todo: AsyncDrawable cannot change destination, so if it's + // attached and not garbage-collected, we can deliver the result. + // Note that there is no cache, so attach/detach of drawables + // will always request a new entry.. (comment @since 3.1.0-SNAPSHOT) + final AsyncDrawable asyncDrawable = reference.get(); + if (asyncDrawable != null && asyncDrawable.isAttached()) { + asyncDrawable.setResult(out); + } + } + + requests.remove(reference); + cleanUp(); + } + }); + } + }); + } + + private static boolean shouldCleanUp(@Nullable AsyncDrawable drawable) { + return drawable == null || !drawable.isAttached(); + } + + @SuppressWarnings("BooleanMethodIsAlwaysInverted") + private static boolean isMainThread() { + return Looper.myLooper() == Looper.getMainLooper(); + } +} diff --git a/markwon-image/src/main/java/ru/noties/markwon/image/ImageItem.java b/markwon-image/src/main/java/ru/noties/markwon/image/ImageItem.java new file mode 100644 index 00000000..df07e768 --- /dev/null +++ b/markwon-image/src/main/java/ru/noties/markwon/image/ImageItem.java @@ -0,0 +1,154 @@ +package ru.noties.markwon.image; + +import android.graphics.drawable.Drawable; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import java.io.InputStream; + +/** + * @since 2.0.0 + */ +public abstract class ImageItem { + + /** + * Create an {@link ImageItem} with result, so no further decoding is required. + * + * @see #withDecodingNeeded(String, InputStream) + * @see WithResult + * @since 4.0.0-SNAPSHOT + */ + @NonNull + public static ImageItem withResult(@NonNull Drawable drawable) { + return new WithResult(drawable); + } + + /** + * Create an {@link ImageItem} that requires further decoding of InputStream. + * + * @see #withResult(Drawable) + * @see WithDecodingNeeded + * @since 4.0.0-SNAPSHOT + */ + @NonNull + public static ImageItem withDecodingNeeded( + @Nullable String contentType, + @NonNull InputStream inputStream) { + return new WithDecodingNeeded(contentType, inputStream); + } + + + private ImageItem() { + } + + /** + * @since 4.0.0-SNAPSHOT + */ + public abstract boolean hasResult(); + + /** + * @since 4.0.0-SNAPSHOT + */ + public abstract boolean hasDecodingNeeded(); + + /** + * @see #hasResult() + * @since 4.0.0-SNAPSHOT + */ + @NonNull + public abstract WithResult getAsWithResult(); + + /** + * @see #hasDecodingNeeded() + * @since 4.0.0-SNAPSHOT + */ + @NonNull + public abstract WithDecodingNeeded getAsWithDecodingNeeded(); + + /** + * @since 4.0.0-SNAPSHOT + */ + public static class WithResult extends ImageItem { + + private final Drawable result; + + private WithResult(@NonNull Drawable drawable) { + result = drawable; + } + + @NonNull + public Drawable result() { + return result; + } + + @Override + public boolean hasResult() { + return true; + } + + @Override + public boolean hasDecodingNeeded() { + return false; + } + + @NonNull + @Override + public WithResult getAsWithResult() { + return this; + } + + @NonNull + @Override + public WithDecodingNeeded getAsWithDecodingNeeded() { + throw new IllegalStateException(); + } + } + + /** + * @since 4.0.0-SNAPSHOT + */ + public static class WithDecodingNeeded extends ImageItem { + + private final String contentType; + private final InputStream inputStream; + + private WithDecodingNeeded( + @Nullable String contentType, + @NonNull InputStream inputStream) { + this.contentType = contentType; + this.inputStream = inputStream; + } + + @Nullable + public String contentType() { + return contentType; + } + + @NonNull + public InputStream inputStream() { + return inputStream; + } + + @Override + public boolean hasResult() { + return false; + } + + @Override + public boolean hasDecodingNeeded() { + return true; + } + + @NonNull + @Override + public WithResult getAsWithResult() { + throw new IllegalStateException(); + } + + @NonNull + @Override + public WithDecodingNeeded getAsWithDecodingNeeded() { + return this; + } + } +} diff --git a/markwon-image/src/main/java/ru/noties/markwon/image/ImagesPlugin.java b/markwon-image/src/main/java/ru/noties/markwon/image/ImagesPlugin.java new file mode 100644 index 00000000..3d441f7b --- /dev/null +++ b/markwon-image/src/main/java/ru/noties/markwon/image/ImagesPlugin.java @@ -0,0 +1,135 @@ +package ru.noties.markwon.image; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.text.Spanned; +import android.widget.TextView; + +import org.commonmark.node.Image; +import org.commonmark.node.Link; +import org.commonmark.node.Node; + +import ru.noties.markwon.AbstractMarkwonPlugin; +import ru.noties.markwon.MarkwonConfiguration; +import ru.noties.markwon.MarkwonSpansFactory; +import ru.noties.markwon.MarkwonVisitor; +import ru.noties.markwon.RenderProps; +import ru.noties.markwon.SpanFactory; + +public class ImagesPlugin extends AbstractMarkwonPlugin { + + /** + * @since 4.0.0-SNAPSHOT + */ + public interface DrawableProvider { + @Nullable + Drawable provide(); + } + + + @NonNull + public static ImagesPlugin create(@NonNull Context context) { + return new ImagesPlugin(context, false); + } + + /** + * Special scheme that is used {@code file:///android_asset/} + * + * @param context + * @return + */ + @NonNull + public static ImagesPlugin createWithAssets(@NonNull Context context) { + return new ImagesPlugin(context, true); + } + + private final Context context; + private final boolean useAssets; + + protected ImagesPlugin(Context context, boolean useAssets) { + this.context = context; + this.useAssets = useAssets; + } + + // we must expose scheme handling... so it's available during construction and via `require` + +// @Override +// public void configureImages(@NonNull AsyncDrawableLoader.Builder builder) { +// +// final FileSchemeHandler fileSchemeHandler = useAssets +// ? FileSchemeHandler.createWithAssets(context.getAssets()) +// : FileSchemeHandler.create(); +// +// builder +// .addSchemeHandler(DataUriSchemeHandler.SCHEME, DataUriSchemeHandler.create()) +// .addSchemeHandler(FileSchemeHandler.SCHEME, fileSchemeHandler) +// .addSchemeHandler( +// Arrays.asList( +// NetworkSchemeHandler.SCHEME_HTTP, +// NetworkSchemeHandler.SCHEME_HTTPS), +// NetworkSchemeHandler.create()) +// .defaultMediaDecoder(ImageMediaDecoder.create(context.getResources())); +// } + + @Override + public void configureSpansFactory(@NonNull MarkwonSpansFactory.Builder builder) { + builder.setFactory(Image.class, new ImageSpanFactory()); + } + + @Override + public void configureVisitor(@NonNull MarkwonVisitor.Builder builder) { + builder.on(Image.class, new MarkwonVisitor.NodeVisitor() { + @Override + public void visit(@NonNull MarkwonVisitor visitor, @NonNull Image image) { + + // if there is no image spanFactory, ignore + final SpanFactory spanFactory = visitor.configuration().spansFactory().get(Image.class); + if (spanFactory == null) { + visitor.visitChildren(image); + return; + } + + final int length = visitor.length(); + + visitor.visitChildren(image); + + // we must check if anything _was_ added, as we need at least one char to render + if (length == visitor.length()) { + visitor.builder().append('\uFFFC'); + } + + final MarkwonConfiguration configuration = visitor.configuration(); + + final Node parent = image.getParent(); + final boolean link = parent instanceof Link; + + final String destination = configuration + .urlProcessor() + .process(image.getDestination()); + + final RenderProps props = visitor.renderProps(); + + // apply image properties + // Please note that we explicitly set IMAGE_SIZE to null as we do not clear + // properties after we applied span (we could though) + ImageProps.DESTINATION.set(props, destination); + ImageProps.REPLACEMENT_TEXT_IS_LINK.set(props, link); + ImageProps.IMAGE_SIZE.set(props, null); + + visitor.setSpans(length, spanFactory.getSpans(configuration, props)); + } + }); + } + + @Override + public void beforeSetText(@NonNull TextView textView, @NonNull Spanned markdown) { + AsyncDrawableScheduler.unschedule(textView); + } + + @Override + public void afterSetText(@NonNull TextView textView) { + AsyncDrawableScheduler.schedule(textView); + } +} diff --git a/markwon-image/src/main/java/ru/noties/markwon/image/MediaDecoder.java b/markwon-image/src/main/java/ru/noties/markwon/image/MediaDecoder.java new file mode 100644 index 00000000..ecea7a98 --- /dev/null +++ b/markwon-image/src/main/java/ru/noties/markwon/image/MediaDecoder.java @@ -0,0 +1,29 @@ +package ru.noties.markwon.image; + +import android.graphics.drawable.Drawable; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import java.io.InputStream; + +/** + * @since 3.0.0 + */ +public abstract class MediaDecoder { + + /** + * Changes since 4.0.0-SNAPSHOT: + *

    + *
  • Returns `non-null` drawable
  • + *
  • Added `contentType` method parameter
  • + *
  • Added `throws Exception` to method signature
  • + *
+ * + * @throws Exception since 4.0.0-SNAPSHOT + */ + @NonNull + public abstract Drawable decode( + @Nullable String contentType, + @NonNull InputStream inputStream + ) throws Exception; +} diff --git a/markwon-image/src/main/java/ru/noties/markwon/image/SchemeHandler.java b/markwon-image/src/main/java/ru/noties/markwon/image/SchemeHandler.java new file mode 100644 index 00000000..2c5889ed --- /dev/null +++ b/markwon-image/src/main/java/ru/noties/markwon/image/SchemeHandler.java @@ -0,0 +1,24 @@ +package ru.noties.markwon.image; + +import android.net.Uri; +import android.support.annotation.NonNull; + +/** + * @since 3.0.0 + */ +public abstract class SchemeHandler { + + /** + * Changes since 4.0.0-SNAPSHOT: + *
    + *
  • Returns `non-null` image-item
  • + *
  • added `throws Exception` to method signature
  • + *
+ * + * @throws Exception since 4.0.0-SNAPSHOT + * @see ImageItem#withResult(android.graphics.drawable.Drawable) + * @see ImageItem#withDecodingNeeded(String, java.io.InputStream) + */ + @NonNull + public abstract ImageItem handle(@NonNull String raw, @NonNull Uri uri) throws Exception; +} diff --git a/markwon-image/src/main/java/ru/noties/markwon/image/data/DataUri.java b/markwon-image/src/main/java/ru/noties/markwon/image/data/DataUri.java new file mode 100644 index 00000000..6e812c92 --- /dev/null +++ b/markwon-image/src/main/java/ru/noties/markwon/image/data/DataUri.java @@ -0,0 +1,60 @@ +package ru.noties.markwon.image.data; + +import android.support.annotation.Nullable; + +public class DataUri { + + private final String contentType; + private final boolean base64; + private final String data; + + public DataUri(@Nullable String contentType, boolean base64, @Nullable String data) { + this.contentType = contentType; + this.base64 = base64; + this.data = data; + } + + @Nullable + public String contentType() { + return contentType; + } + + public boolean base64() { + return base64; + } + + @Nullable + public String data() { + return data; + } + + @Override + public String toString() { + return "DataUri{" + + "contentType='" + contentType + '\'' + + ", base64=" + base64 + + ", data='" + data + '\'' + + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + DataUri dataUri = (DataUri) o; + + if (base64 != dataUri.base64) return false; + if (contentType != null ? !contentType.equals(dataUri.contentType) : dataUri.contentType != null) + return false; + return data != null ? data.equals(dataUri.data) : dataUri.data == null; + } + + @Override + public int hashCode() { + int result = contentType != null ? contentType.hashCode() : 0; + result = 31 * result + (base64 ? 1 : 0); + result = 31 * result + (data != null ? data.hashCode() : 0); + return result; + } +} diff --git a/markwon-image/src/main/java/ru/noties/markwon/image/data/DataUriDecoder.java b/markwon-image/src/main/java/ru/noties/markwon/image/data/DataUriDecoder.java new file mode 100644 index 00000000..929ff47d --- /dev/null +++ b/markwon-image/src/main/java/ru/noties/markwon/image/data/DataUriDecoder.java @@ -0,0 +1,39 @@ +package ru.noties.markwon.image.data; + +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.text.TextUtils; +import android.util.Base64; + +public abstract class DataUriDecoder { + + @Nullable + public abstract byte[] decode(@NonNull DataUri dataUri) throws Throwable; + + @NonNull + public static DataUriDecoder create() { + return new Impl(); + } + + static class Impl extends DataUriDecoder { + + private static final String CHARSET = "UTF-8"; + + @Nullable + @Override + public byte[] decode(@NonNull DataUri dataUri) throws Throwable { + + final String data = dataUri.data(); + + if (!TextUtils.isEmpty(data)) { + if (dataUri.base64()) { + return Base64.decode(data.getBytes(CHARSET), Base64.DEFAULT); + } else { + return data.getBytes(CHARSET); + } + } else { + return null; + } + } + } +} diff --git a/markwon-image/src/main/java/ru/noties/markwon/image/data/DataUriParser.java b/markwon-image/src/main/java/ru/noties/markwon/image/data/DataUriParser.java new file mode 100644 index 00000000..0768ee4a --- /dev/null +++ b/markwon-image/src/main/java/ru/noties/markwon/image/data/DataUriParser.java @@ -0,0 +1,79 @@ +package ru.noties.markwon.image.data; + +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +public abstract class DataUriParser { + + @Nullable + public abstract DataUri parse(@NonNull String input); + + + @NonNull + public static DataUriParser create() { + return new Impl(); + } + + static class Impl extends DataUriParser { + + @Nullable + @Override + public DataUri parse(@NonNull String input) { + + final int index = input.indexOf(','); + // we expect exactly one comma + if (index < 0) { + return null; + } + + final String contentType; + final boolean base64; + + if (index > 0) { + final String part = input.substring(0, index); + final String[] parts = part.split(";"); + final int length = parts.length; + if (length > 0) { + // if one: either content-type or base64 + if (length == 1) { + final String value = parts[0]; + if ("base64".equals(value)) { + contentType = null; + base64 = true; + } else { + contentType = value.indexOf('/') > -1 + ? value + : null; + base64 = false; + } + } else { + contentType = parts[0].indexOf('/') > -1 + ? parts[0] + : null; + base64 = "base64".equals(parts[length - 1]); + } + } else { + contentType = null; + base64 = false; + } + } else { + contentType = null; + base64 = false; + } + + final String data; + if (index < input.length()) { + final String value = input.substring(index + 1, input.length()).replaceAll("\n", ""); + if (value.length() == 0) { + data = null; + } else { + data = value; + } + } else { + data = null; + } + + return new DataUri(contentType, base64, data); + } + } +} diff --git a/markwon-image/src/main/java/ru/noties/markwon/image/data/DataUriSchemeHandler.java b/markwon-image/src/main/java/ru/noties/markwon/image/data/DataUriSchemeHandler.java new file mode 100644 index 00000000..cd4bd570 --- /dev/null +++ b/markwon-image/src/main/java/ru/noties/markwon/image/data/DataUriSchemeHandler.java @@ -0,0 +1,64 @@ +package ru.noties.markwon.image.data; + +import android.net.Uri; +import android.support.annotation.NonNull; + +import java.io.ByteArrayInputStream; + +import ru.noties.markwon.image.ImageItem; +import ru.noties.markwon.image.SchemeHandler; + +/** + * @since 2.0.0 + */ +public class DataUriSchemeHandler extends SchemeHandler { + + public static final String SCHEME = "data"; + + @NonNull + public static DataUriSchemeHandler create() { + return new DataUriSchemeHandler(DataUriParser.create(), DataUriDecoder.create()); + } + + private static final String START = "data:"; + + private final DataUriParser uriParser; + private final DataUriDecoder uriDecoder; + + @SuppressWarnings("WeakerAccess") + DataUriSchemeHandler(@NonNull DataUriParser uriParser, @NonNull DataUriDecoder uriDecoder) { + this.uriParser = uriParser; + this.uriDecoder = uriDecoder; + } + + @NonNull + @Override + public ImageItem handle(@NonNull String raw, @NonNull Uri uri) { + + if (!raw.startsWith(START)) { + throw new IllegalStateException("Invalid data-uri: " + raw); + } + + final String part = raw.substring(START.length()); + + final DataUri dataUri = uriParser.parse(part); + if (dataUri == null) { + throw new IllegalStateException("Invalid data-uri: " + raw); + } + + final byte[] bytes; + try { + bytes = uriDecoder.decode(dataUri); + } catch (Throwable t) { + throw new IllegalStateException("Cannot decode data-uri: " + raw, t); + } + + if (bytes == null) { + throw new IllegalStateException("Decoding data-uri failed: " + raw); + } + + return ImageItem.withDecodingNeeded( + dataUri.contentType(), + new ByteArrayInputStream(bytes)); + } +} diff --git a/markwon-image/src/main/java/ru/noties/markwon/image/file/FileSchemeHandler.java b/markwon-image/src/main/java/ru/noties/markwon/image/file/FileSchemeHandler.java new file mode 100644 index 00000000..cd5030c1 --- /dev/null +++ b/markwon-image/src/main/java/ru/noties/markwon/image/file/FileSchemeHandler.java @@ -0,0 +1,114 @@ +package ru.noties.markwon.image.file; + +import android.content.res.AssetManager; +import android.net.Uri; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.text.TextUtils; +import android.webkit.MimeTypeMap; + +import java.io.BufferedInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.util.List; + +import ru.noties.markwon.image.ImageItem; +import ru.noties.markwon.image.SchemeHandler; + +/** + * @since 3.0.0 + */ +public class FileSchemeHandler extends SchemeHandler { + + public static final String SCHEME = "file"; + + /** + * @see ru.noties.markwon.urlprocessor.UrlProcessorAndroidAssets + */ + @NonNull + public static FileSchemeHandler createWithAssets(@NonNull AssetManager assetManager) { + return new FileSchemeHandler(assetManager); + } + + @NonNull + public static FileSchemeHandler create() { + return new FileSchemeHandler(null); + } + + private static final String FILE_ANDROID_ASSETS = "android_asset"; + + @Nullable + private final AssetManager assetManager; + + @SuppressWarnings("WeakerAccess") + FileSchemeHandler(@Nullable AssetManager assetManager) { + this.assetManager = assetManager; + } + + @NonNull + @Override + public ImageItem handle(@NonNull String raw, @NonNull Uri uri) { + + final List segments = uri.getPathSegments(); + if (segments == null + || segments.size() == 0) { + // pointing to file & having no path segments is no use + throw new IllegalStateException("Invalid file path: " + raw); + } + + final InputStream inputStream; + + final boolean assets = FILE_ANDROID_ASSETS.equals(segments.get(0)); + final String fileName = uri.getLastPathSegment(); + + if (assets) { + + // no handling of assets here if we have no assetsManager + if (assetManager != null) { + + final StringBuilder path = new StringBuilder(); + for (int i = 1, size = segments.size(); i < size; i++) { + if (i != 1) { + path.append('/'); + } + path.append(segments.get(i)); + } + // load assets + + try { + inputStream = assetManager.open(path.toString()); + } catch (IOException e) { + throw new IllegalStateException("Exception obtaining asset file: " + + "" + raw + ", path: " + path.toString(), e); + } + } else { + throw new IllegalStateException("Supplied file path points to assets, " + + "but FileSchemeHandler was not supplied with AssetsManager. " + + "Use `#createWithAssets` factory method to create FileSchemeHandler " + + "that can handle android assets"); + } + + } else { + + final String path = uri.getPath(); + if (TextUtils.isEmpty(path)) { + throw new IllegalStateException("Invalid file path: " + raw + ", " + path); + } + + try { + inputStream = new BufferedInputStream(new FileInputStream(new File(path))); + } catch (FileNotFoundException e) { + throw new IllegalStateException("Exception reading file: " + raw, e); + } + } + + final String contentType = MimeTypeMap + .getSingleton() + .getMimeTypeFromExtension(MimeTypeMap.getFileExtensionFromUrl(fileName)); + + return ImageItem.withDecodingNeeded(contentType, inputStream); + } +} diff --git a/markwon-image/src/main/java/ru/noties/markwon/image/gif/GifMediaDecoder.java b/markwon-image/src/main/java/ru/noties/markwon/image/gif/GifMediaDecoder.java new file mode 100644 index 00000000..4d0cbe32 --- /dev/null +++ b/markwon-image/src/main/java/ru/noties/markwon/image/gif/GifMediaDecoder.java @@ -0,0 +1,77 @@ +package ru.noties.markwon.image.gif; + +import android.graphics.drawable.Drawable; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; + +import pl.droidsonroids.gif.GifDrawable; +import ru.noties.markwon.image.DrawableUtils; +import ru.noties.markwon.image.MediaDecoder; + +/** + * @since 1.1.0 + */ +@SuppressWarnings("WeakerAccess") +public class GifMediaDecoder extends MediaDecoder { + + public static final String CONTENT_TYPE = "image/gif"; + + @NonNull + public static GifMediaDecoder create(boolean autoPlayGif) { + return new GifMediaDecoder(autoPlayGif); + } + + private final boolean autoPlayGif; + + protected GifMediaDecoder(boolean autoPlayGif) { + this.autoPlayGif = autoPlayGif; + } + + @NonNull + @Override + public Drawable decode(@Nullable String contentType, @NonNull InputStream inputStream) { + + final byte[] bytes; + try { + bytes = readBytes(inputStream); + } catch (IOException e) { + throw new IllegalStateException("Cannot read GIF input-stream", e); + } + + final GifDrawable drawable; + try { + drawable = newGifDrawable(bytes); + } catch (IOException e) { + throw new IllegalStateException("Exception creating GifDrawable", e); + } + + DrawableUtils.applyIntrinsicBounds(drawable); + + if (!autoPlayGif) { + drawable.pause(); + } + + return drawable; + } + + @NonNull + protected GifDrawable newGifDrawable(@NonNull byte[] bytes) throws IOException { + return new GifDrawable(bytes); + } + + @NonNull + protected static byte[] readBytes(@NonNull InputStream stream) throws IOException { + final ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + final int length = 1024 * 8; + final byte[] buffer = new byte[length]; + int read; + while ((read = stream.read(buffer, 0, length)) != -1) { + outputStream.write(buffer, 0, read); + } + return outputStream.toByteArray(); + } +} diff --git a/markwon-image/src/main/java/ru/noties/markwon/image/network/NetworkSchemeHandler.java b/markwon-image/src/main/java/ru/noties/markwon/image/network/NetworkSchemeHandler.java new file mode 100644 index 00000000..c48d54d3 --- /dev/null +++ b/markwon-image/src/main/java/ru/noties/markwon/image/network/NetworkSchemeHandler.java @@ -0,0 +1,73 @@ +package ru.noties.markwon.image.network; + +import android.net.Uri; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import java.io.BufferedInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; + +import ru.noties.markwon.image.ImageItem; +import ru.noties.markwon.image.SchemeHandler; + +/** + * A simple network scheme handler that is not dependent on any external libraries. + * + * @see #create() + * @since 3.0.0 + */ +public class NetworkSchemeHandler extends SchemeHandler { + + public static final String SCHEME_HTTP = "http"; + public static final String SCHEME_HTTPS = "https"; + + @NonNull + public static NetworkSchemeHandler create() { + return new NetworkSchemeHandler(); + } + + @NonNull + @Override + public ImageItem handle(@NonNull String raw, @NonNull Uri uri) { + + final ImageItem imageItem; + try { + + final URL url = new URL(raw); + final HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.connect(); + + final int responseCode = connection.getResponseCode(); + if (responseCode >= 200 && responseCode < 300) { + final String contentType = contentType(connection.getHeaderField("Content-Type")); + final InputStream inputStream = new BufferedInputStream(connection.getInputStream()); + imageItem = ImageItem.withDecodingNeeded(contentType, inputStream); + } else { + throw new IOException("Bad response code: " + responseCode + ", url: " + raw); + } + + } catch (IOException e) { + throw new IllegalStateException("Exception obtaining network resource: " + raw, e); + } + + return imageItem; + } + + @Nullable + static String contentType(@Nullable String contentType) { + + if (contentType == null) { + return null; + } + + final int index = contentType.indexOf(';'); + if (index > -1) { + return contentType.substring(0, index); + } + + return contentType; + } +} diff --git a/markwon-image/src/main/java/ru/noties/markwon/image/network/OkHttpNetworkSchemeHandler.java b/markwon-image/src/main/java/ru/noties/markwon/image/network/OkHttpNetworkSchemeHandler.java new file mode 100644 index 00000000..0433fd13 --- /dev/null +++ b/markwon-image/src/main/java/ru/noties/markwon/image/network/OkHttpNetworkSchemeHandler.java @@ -0,0 +1,75 @@ +package ru.noties.markwon.image.network; + +import android.net.Uri; +import android.support.annotation.NonNull; + +import java.io.IOException; +import java.io.InputStream; + +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; +import ru.noties.markwon.image.ImageItem; +import ru.noties.markwon.image.SchemeHandler; + +/** + * @since 4.0.0-SNAPSHOT + */ +class OkHttpNetworkSchemeHandler extends SchemeHandler { + + /** + * @see #create(OkHttpClient) + */ + @NonNull + public static OkHttpNetworkSchemeHandler create() { + return new OkHttpNetworkSchemeHandler(new OkHttpClient()); + } + + @NonNull + public static OkHttpNetworkSchemeHandler create(@NonNull OkHttpClient client) { + return new OkHttpNetworkSchemeHandler(client); + } + + private static final String HEADER_CONTENT_TYPE = "Content-Type"; + + private final OkHttpClient client; + + OkHttpNetworkSchemeHandler(@NonNull OkHttpClient client) { + this.client = client; + } + + @NonNull + @Override + public ImageItem handle(@NonNull String raw, @NonNull Uri uri) { + + final Request request = new Request.Builder() + .url(raw) + .tag(raw) + .build(); + + final Response response; + try { + response = client.newCall(request).execute(); + } catch (IOException e) { + throw new IllegalStateException("Exception obtaining network resource: " + raw, e); + } + + if (response == null) { + throw new IllegalStateException("Could not obtain network response: " + raw); + } + + final ResponseBody body = response.body(); + final InputStream inputStream = body != null + ? body.byteStream() + : null; + + if (inputStream == null) { + throw new IllegalStateException("Response does not contain body: " + raw); + } + + final String contentType = response.header(HEADER_CONTENT_TYPE); + + return ImageItem.withDecodingNeeded(contentType, inputStream); + } +} diff --git a/markwon-image/src/main/java/ru/noties/markwon/image/svg/SvgMediaDecoder.java b/markwon-image/src/main/java/ru/noties/markwon/image/svg/SvgMediaDecoder.java new file mode 100644 index 00000000..cba9fb39 --- /dev/null +++ b/markwon-image/src/main/java/ru/noties/markwon/image/svg/SvgMediaDecoder.java @@ -0,0 +1,74 @@ +package ru.noties.markwon.image.svg; + +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import com.caverock.androidsvg.SVG; +import com.caverock.androidsvg.SVGParseException; + +import java.io.InputStream; + +import ru.noties.markwon.image.DrawableUtils; +import ru.noties.markwon.image.MediaDecoder; + +/** + * @since 1.1.0 + */ +public class SvgMediaDecoder extends MediaDecoder { + + public static final String CONTENT_TYPE = "image/svg+xml"; + + /** + * @see #create(Resources) + * @since 4.0.0-SNAPSHOT + */ + @NonNull + public static SvgMediaDecoder create() { + return new SvgMediaDecoder(Resources.getSystem()); + } + + @NonNull + public static SvgMediaDecoder create(@NonNull Resources resources) { + return new SvgMediaDecoder(resources); + } + + private final Resources resources; + + @SuppressWarnings("WeakerAccess") + SvgMediaDecoder(Resources resources) { + this.resources = resources; + } + + @NonNull + @Override + public Drawable decode(@Nullable String contentType, @NonNull InputStream inputStream) { + + final SVG svg; + try { + svg = SVG.getFromInputStream(inputStream); + } catch (SVGParseException e) { + throw new IllegalStateException("Exception decoding SVG", e); + } + + final float w = svg.getDocumentWidth(); + final float h = svg.getDocumentHeight(); + final float density = resources.getDisplayMetrics().density; + + final int width = (int) (w * density + .5F); + final int height = (int) (h * density + .5F); + + final Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_4444); + final Canvas canvas = new Canvas(bitmap); + canvas.scale(density, density); + svg.renderToCanvas(canvas); + + final Drawable drawable = new BitmapDrawable(resources, bitmap); + DrawableUtils.applyIntrinsicBounds(drawable); + return drawable; + } +} diff --git a/settings.gradle b/settings.gradle index faf09343..258291b4 100644 --- a/settings.gradle +++ b/settings.gradle @@ -6,6 +6,7 @@ include ':app', ':sample', ':markwon-ext-tables', ':markwon-ext-tasklist', ':markwon-html', + ':markwon-image', ':markwon-image-gif', ':markwon-image-okhttp', ':markwon-image-svg',