diff --git a/README.md b/README.md index 382ec771..bb5433a6 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,28 @@ Lorem ipsum dolor sit amet Lorem ipsum dolor sit amet ``` +### H.T.M.L. +<b>O</b><i>K<s>A</s><sup>42<sup>43<sub><b>42</b></sub></sup></sup><u>Y</u></i> + +<img src="h" /> <img src="h"> +<img src="h" alt="alt text"> + +<hr> + +<hr /> + +<h1>Hello</h1> + +<h2>Hello</h2> + +<h3>Hello</h3> + +<h4>Hello</h4> + +<h5>Hello</h5> + +<h6>Hello</h6> + [1]: https://github.com [github]: https://github.com diff --git a/app/build.gradle b/app/build.gradle index 608dece2..2e9465b9 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -15,12 +15,14 @@ android { } dependencies { + compile project(':library-renderer') + compile project(':library-image-loader') + compile 'ru.noties:debug:3.0.0@jar' - compile 'com.squareup.picasso:picasso:2.5.2' - compile 'com.caverock:androidsvg:1.2.1' - compile 'pl.droidsonroids.gif:android-gif-drawable:1.2.7' - compile 'com.squareup.okhttp3:okhttp:3.8.0' + + compile OK_HTTP + compile 'com.google.dagger:dagger:2.10' annotationProcessor 'com.google.dagger:dagger-compiler:2.10' } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index bea26fe9..1cf83a05 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ package="ru.noties.markwon"> <uses-permission android:name="android.permission.INTERNET" /> + <uses-permission android:name="android.permission.ACCESS_ALL_DOWNLOADS" /> <application android:name=".App" @@ -38,19 +39,19 @@ android:scheme="https" /> <!--<data--> - <!--android:host="*"--> - <!--android:scheme="http"--> - <!--android:mimeType="text/markdown"/>--> + <!--android:host="*"--> + <!--android:scheme="http"--> + <!--android:mimeType="text/markdown"/>--> <!--<data--> - <!--android:host="*"--> - <!--android:scheme="file"--> - <!--android:mimeType="text/markdown"/>--> + <!--android:host="*"--> + <!--android:scheme="file"--> + <!--android:mimeType="text/markdown"/>--> <!--<data--> - <!--android:host="*"--> - <!--android:scheme="https"--> - <!--android:mimeType="text/markdown"/>--> + <!--android:host="*"--> + <!--android:scheme="https"--> + <!--android:mimeType="text/markdown"/>--> <data android:pathPattern=".*\\.markdown" /> <data android:pathPattern=".*\\.mdown" /> diff --git a/app/src/main/java/ru/noties/markwon/App.java b/app/src/main/java/ru/noties/markwon/App.java index 6b185d78..5c9b3157 100644 --- a/app/src/main/java/ru/noties/markwon/App.java +++ b/app/src/main/java/ru/noties/markwon/App.java @@ -4,6 +4,9 @@ import android.app.Application; import android.content.Context; import android.support.annotation.NonNull; +import ru.noties.debug.AndroidLogDebugOutput; +import ru.noties.debug.Debug; + public class App extends Application { private AppComponent component; @@ -12,6 +15,8 @@ public class App extends Application { public void onCreate() { super.onCreate(); + Debug.init(new AndroidLogDebugOutput(BuildConfig.DEBUG)); + component = DaggerAppComponent.builder() .appModule(new AppModule(this)) .build(); diff --git a/app/src/main/java/ru/noties/markwon/AppModule.java b/app/src/main/java/ru/noties/markwon/AppModule.java index 9a8f5a86..7d04a4db 100644 --- a/app/src/main/java/ru/noties/markwon/AppModule.java +++ b/app/src/main/java/ru/noties/markwon/AppModule.java @@ -5,8 +5,6 @@ import android.content.res.Resources; import android.os.Handler; import android.os.Looper; -import com.squareup.picasso.Picasso; - import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -14,7 +12,10 @@ import javax.inject.Singleton; import dagger.Module; import dagger.Provides; +import okhttp3.Cache; import okhttp3.OkHttpClient; +import ru.noties.markwon.il.AsyncDrawableLoader; +import ru.noties.markwon.spans.AsyncDrawable; @Module class AppModule { @@ -38,7 +39,10 @@ class AppModule { @Provides @Singleton OkHttpClient client() { - return new OkHttpClient(); + return new OkHttpClient.Builder() + .cache(new Cache(app.getCacheDir(), 1024L * 20)) + .followRedirects(true) + .build(); } @Singleton @@ -60,7 +64,14 @@ class AppModule { } @Provides - Picasso picasso(Context context) { - return Picasso.with(context); + AsyncDrawable.Loader asyncDrawableLoader( + OkHttpClient client, + ExecutorService executorService, + Resources resources) { + return AsyncDrawableLoader.builder() + .client(client) + .executorService(executorService) + .resources(resources) + .build(); } } diff --git a/app/src/main/java/ru/noties/markwon/AsyncDrawableLoader.java b/app/src/main/java/ru/noties/markwon/AsyncDrawableLoader.java deleted file mode 100644 index d349e498..00000000 --- a/app/src/main/java/ru/noties/markwon/AsyncDrawableLoader.java +++ /dev/null @@ -1,155 +0,0 @@ -package ru.noties.markwon; - -import android.content.res.Resources; -import android.graphics.Bitmap; -import android.graphics.Canvas; -import android.graphics.drawable.BitmapDrawable; -import android.graphics.drawable.ColorDrawable; -import android.graphics.drawable.Drawable; -import android.support.annotation.NonNull; - -import com.caverock.androidsvg.SVG; -import com.squareup.picasso.Picasso; - -import java.io.ByteArrayOutputStream; -import java.io.InputStream; -import java.net.HttpURLConnection; -import java.net.URL; -import java.util.HashMap; -import java.util.Map; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Future; - -import javax.inject.Inject; - -import pl.droidsonroids.gif.GifDrawable; -import ru.noties.debug.Debug; -import ru.noties.markwon.spans.AsyncDrawable; - -@ActivityScope -public class AsyncDrawableLoader implements AsyncDrawable.Loader { - - @Inject - Resources resources; - - @Inject - Picasso picasso; - - @Inject - ExecutorService executorService; - - private final Map<String, Future<?>> requests = new HashMap<>(3); - private final CopyOnWriteArrayList<AsyncDrawableTarget> targets = new CopyOnWriteArrayList<>(); - - // sh*t.. - @Inject - public AsyncDrawableLoader() { - } - - @Override - public void load(@NonNull String destination, @NonNull AsyncDrawable drawable) { - - if (destination.endsWith(".svg")) { - // load svg - requests.put(destination, loadSvg(destination, drawable)); - } else if (destination.endsWith(".gif")) { - requests.put(destination, loadGif(destination, drawable)); - } else { - - final Drawable error = new ColorDrawable(0xFFff0000); - final Drawable placeholder = new ColorDrawable(0xFF00ff00); - error.setBounds(0, 0, 100, 100); - placeholder.setBounds(0, 0, 50, 50); - - final AsyncDrawableTarget target = new AsyncDrawableTarget(resources, drawable, new AsyncDrawableTarget.DoneListener() { - @Override - public void onLoadingDone(AsyncDrawableTarget target) { - targets.remove(target); - } - }); - - targets.add(target); - - picasso - .load(destination) - .tag(destination) - .placeholder(placeholder) - .error(error) - .into(target); - } - } - - @Override - public void cancel(@NonNull String destination) { - Debug.i("destination: %s", destination); - picasso.cancelTag(destination); - - final Future<?> future = requests.remove(destination); - if (future != null) { - future.cancel(true); - } - } - - private Future<?> loadSvg(final String destination, final AsyncDrawable asyncDrawable) { - return executorService.submit(new Runnable() { - @Override - public void run() { - try { - - final URL url = new URL(destination); - final HttpURLConnection connection = (HttpURLConnection) url.openConnection(); - final InputStream inputStream = connection.getInputStream(); - - final SVG svg = SVG.getFromInputStream(inputStream); - final float w = svg.getDocumentWidth(); - final float h = svg.getDocumentHeight(); - Debug.i("w: %s, h: %s", w, h); - - final float density = resources.getDisplayMetrics().density; - Debug.i(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); - drawable.setBounds(0, 0, drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight()); - asyncDrawable.setResult(drawable); - - } catch (Throwable t) { - Debug.e(t); - } - } - }); - } - - private Future<?> loadGif(final String destination, final AsyncDrawable asyncDrawable) { - return executorService.submit(new Runnable() { - @Override - public void run() { - try { - - final URL url = new URL(destination); - final HttpURLConnection connection = (HttpURLConnection) url.openConnection(); - final InputStream inputStream = connection.getInputStream(); - - final ByteArrayOutputStream baos = new ByteArrayOutputStream(); - final byte[] buffer = new byte[1024 * 8]; - int read; - while ((read = inputStream.read(buffer, 0, buffer.length)) != -1) { - baos.write(buffer, 0, read); - } - final GifDrawable drawable = new GifDrawable(baos.toByteArray()); - drawable.setBounds(0, 0, drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight()); - asyncDrawable.setResult(drawable); - } catch (Throwable t) { - Debug.e(t); - } - } - }); - } -} diff --git a/app/src/main/java/ru/noties/markwon/AsyncDrawableTarget.java b/app/src/main/java/ru/noties/markwon/AsyncDrawableTarget.java deleted file mode 100644 index 1de0eaa1..00000000 --- a/app/src/main/java/ru/noties/markwon/AsyncDrawableTarget.java +++ /dev/null @@ -1,81 +0,0 @@ -package ru.noties.markwon; - -import android.content.res.Resources; -import android.graphics.Bitmap; -import android.graphics.drawable.BitmapDrawable; -import android.graphics.drawable.Drawable; - -import com.squareup.picasso.Picasso; -import com.squareup.picasso.Target; - -import ru.noties.markwon.spans.AsyncDrawable; - -public class AsyncDrawableTarget implements Target { - - interface DoneListener { - void onLoadingDone(AsyncDrawableTarget target); - } - - private final Resources resources; - private final AsyncDrawable asyncDrawable; - private final DoneListener listener; - - public AsyncDrawableTarget(Resources resources, AsyncDrawable asyncDrawable, DoneListener listener) { - this.resources = resources; - this.asyncDrawable = asyncDrawable; - this.listener = listener; - } - - @Override - public void onBitmapLoaded(Bitmap bitmap, Picasso.LoadedFrom from) { - if (bitmap != null) { - final Drawable drawable = new BitmapDrawable(resources, bitmap); - drawable.setBounds(0, 0, drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight()); - asyncDrawable.setResult(drawable); - } - notifyDone(); - } - - @Override - public void onBitmapFailed(Drawable errorDrawable) { - if (errorDrawable != null) { - asyncDrawable.setResult(errorDrawable); - } - notifyDone(); - } - - @Override - public void onPrepareLoad(Drawable placeHolderDrawable) { - if (placeHolderDrawable != null) { - asyncDrawable.setResult(placeHolderDrawable); - } - } - - private void notifyDone() { - if (listener != null) { - listener.onLoadingDone(this); - } - } -// -// private void attach() { -// -// // amazing stuff here, in order to keep this target alive (picasso stores target in a WeakReference) -// // we need to do this -// -// //noinspection unchecked -// List<AsyncDrawableTarget> list = (List<AsyncDrawableTarget>) view.getTag(R.id.amazing); -// if (list == null) { -// list = new ArrayList<>(2); -// view.setTag(R.id.amazing, list); -// } -// list.add(this); -// } -// -// private void detach() { -// //noinspection unchecked -// final List<AsyncDrawableTarget> list = (List<AsyncDrawableTarget>) view.getTag(R.id.amazing); -// if (list != null) { -// list.remove(this); -// } -// } -} diff --git a/app/src/main/java/ru/noties/markwon/CollectionUtils.java b/app/src/main/java/ru/noties/markwon/CollectionUtils.java deleted file mode 100644 index 7e3cad92..00000000 --- a/app/src/main/java/ru/noties/markwon/CollectionUtils.java +++ /dev/null @@ -1,12 +0,0 @@ -package ru.noties.markwon; - -import java.util.Collection; - -public abstract class CollectionUtils { - - public static boolean isEmpty(Collection<?> collection) { - return collection == null || collection.size() == 0; - } - - private CollectionUtils() {} -} diff --git a/app/src/main/java/ru/noties/markwon/MainActivity.java b/app/src/main/java/ru/noties/markwon/MainActivity.java index 30f1995d..ba311cbd 100644 --- a/app/src/main/java/ru/noties/markwon/MainActivity.java +++ b/app/src/main/java/ru/noties/markwon/MainActivity.java @@ -9,15 +9,10 @@ import android.widget.TextView; import javax.inject.Inject; -import ru.noties.debug.AndroidLogDebugOutput; import ru.noties.debug.Debug; public class MainActivity extends Activity { - static { - Debug.init(new AndroidLogDebugOutput(true)); - } - @Inject MarkdownLoader markdownLoader; @@ -112,6 +107,16 @@ public class MainActivity extends Activity { : null; } + @Override + protected void onRestoreInstanceState(Bundle savedInstanceState) { + try { + super.onRestoreInstanceState(savedInstanceState); + } catch (Throwable t) { + // amazing stuff, we need this because on JB it will crash otherwise with NPE + Debug.e(t); + } + } + @Override protected void onDestroy() { super.onDestroy(); diff --git a/app/src/main/java/ru/noties/markwon/MarkdownLoader.java b/app/src/main/java/ru/noties/markwon/MarkdownLoader.java index 0e186765..02bb902d 100644 --- a/app/src/main/java/ru/noties/markwon/MarkdownLoader.java +++ b/app/src/main/java/ru/noties/markwon/MarkdownLoader.java @@ -72,13 +72,20 @@ public class MarkdownLoader { } } + private boolean isCancelled() { + return task == null || task.isCancelled(); + } + private void deliver(@NonNull final OnMarkdownTextLoaded loaded, final String text) { - if (task != null - && !task.isCancelled()) { + if (!isCancelled()) { handler.post(new Runnable() { @Override public void run() { - loaded.apply(text); + // as this call is async, we need to check again if we are cancelled + if (!isCancelled()) { + loaded.apply(text); + task = null; + } } }); } diff --git a/app/src/main/java/ru/noties/markwon/MarkdownRenderer.java b/app/src/main/java/ru/noties/markwon/MarkdownRenderer.java index 002719e1..1d0c4dc5 100644 --- a/app/src/main/java/ru/noties/markwon/MarkdownRenderer.java +++ b/app/src/main/java/ru/noties/markwon/MarkdownRenderer.java @@ -17,6 +17,7 @@ import java.util.concurrent.Future; import javax.inject.Inject; import ru.noties.markwon.renderer.SpannableRenderer; +import ru.noties.markwon.spans.AsyncDrawable; @ActivityScope public class MarkdownRenderer { @@ -26,7 +27,7 @@ public class MarkdownRenderer { } @Inject - AsyncDrawableLoader loader; + AsyncDrawable.Loader loader; @Inject ExecutorService service; @@ -62,22 +63,19 @@ public class MarkdownRenderer { .urlProcessor(urlProcessor) .build(); - final Parser parser = Parser.builder() - .extensions(Collections.singleton(StrikethroughExtension.create())) - .build(); + final CharSequence text = Markwon.markdown(configuration, markdown); - final Node node = parser.parse(markdown); - final SpannableRenderer renderer = new SpannableRenderer(); - final CharSequence text = renderer.render(configuration, node); - -// final CharSequence text = Markwon.markdown(configuration, markdown); - handler.post(new Runnable() { - @Override - public void run() { - listener.onMarkdownReady(text); - } - }); - task = null; + if (!isCancelled()) { + handler.post(new Runnable() { + @Override + public void run() { + if (!isCancelled()) { + listener.onMarkdownReady(text); + task = null; + } + } + }); + } } }); } @@ -88,4 +86,8 @@ public class MarkdownRenderer { task = null; } } + + private boolean isCancelled() { + return task == null || task.isCancelled(); + } } diff --git a/app/src/main/java/ru/noties/markwon/Themes.java b/app/src/main/java/ru/noties/markwon/Themes.java index 05e51747..d6a9ae8b 100644 --- a/app/src/main/java/ru/noties/markwon/Themes.java +++ b/app/src/main/java/ru/noties/markwon/Themes.java @@ -22,7 +22,7 @@ public class Themes { public void apply(@NonNull Context context) { final boolean dark = preferences.getBoolean(KEY_THEME_DARK, false); - // we have only 2 themes and Light one is default, so no need to apply it + // we have only 2 themes and Light one is default final int theme; if (dark) { theme = R.style.AppThemeBaseDark; diff --git a/app/src/main/java/ru/noties/markwon/Views.java b/app/src/main/java/ru/noties/markwon/Views.java index 57c283ca..b9f692d0 100644 --- a/app/src/main/java/ru/noties/markwon/Views.java +++ b/app/src/main/java/ru/noties/markwon/Views.java @@ -6,10 +6,12 @@ import android.support.annotation.IntDef; import android.support.annotation.NonNull; import android.view.View; +@SuppressWarnings("WeakerAccess") public abstract class Views { @IntDef({View.INVISIBLE, View.GONE}) - @interface NotVisible {} + @interface NotVisible { + } public static <V extends View> V findView(@NonNull View view, @IdRes int id) { //noinspection unchecked @@ -32,5 +34,6 @@ public abstract class Views { view.setVisibility(visibility); } - private Views() {} + private Views() { + } } diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index b8632f76..d8703dd5 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -6,7 +6,7 @@ <item name="ic_app_bar_theme">@drawable/ic_app_bar_theme_light</item> </style> - <style name="AppThemeBaseLight" parent="android:Theme.Holo.Light"> + <style name="AppThemeBaseLight" parent="android:Theme.Holo.Light.NoActionBar"> <item name="ic_app_bar_theme">@drawable/ic_app_bar_theme_dark</item> </style> diff --git a/build.gradle b/build.gradle index 9b51f6fc..ece44916 100644 --- a/build.gradle +++ b/build.gradle @@ -32,4 +32,8 @@ ext { final def commonMarkVersion = '0.9.0' COMMON_MARK = "com.atlassian.commonmark:commonmark:$commonMarkVersion" COMMON_MARK_STRIKETHROUGHT = "com.atlassian.commonmark:commonmark-ext-gfm-strikethrough:$commonMarkVersion" + + ANDROID_SVG = 'com.caverock:androidsvg:1.2.1' + ANDROID_GIF = 'pl.droidsonroids.gif:android-gif-drawable:1.2.7' + OK_HTTP = 'com.squareup.okhttp3:okhttp:3.8.0' } diff --git a/library-image-loader/build.gradle b/library-image-loader/build.gradle new file mode 100644 index 00000000..3fbfe21a --- /dev/null +++ b/library-image-loader/build.gradle @@ -0,0 +1,25 @@ +apply plugin: 'com.android.library' + +android { + + compileSdkVersion TARGET_SDK + buildToolsVersion BUILD_TOOLS + + defaultConfig { + minSdkVersion MIN_SDK + targetSdkVersion TARGET_SDK + versionCode 1 + versionName version + } +} + +dependencies { + + compile project(':library-renderer') + compile ANDROID_SVG + compile ANDROID_GIF + compile OK_HTTP + + // todo, debugging only + compile 'ru.noties:debug:3.0.0@jar' +} \ No newline at end of file diff --git a/library-image-loader/src/main/AndroidManifest.xml b/library-image-loader/src/main/AndroidManifest.xml new file mode 100644 index 00000000..35da8e8a --- /dev/null +++ b/library-image-loader/src/main/AndroidManifest.xml @@ -0,0 +1 @@ +<manifest package="ru.noties.markwon.il" /> diff --git a/library-image-loader/src/main/java/ru/noties/markwon/il/AsyncDrawableLoader.java b/library-image-loader/src/main/java/ru/noties/markwon/il/AsyncDrawableLoader.java new file mode 100644 index 00000000..9b0feae0 --- /dev/null +++ b/library-image-loader/src/main/java/ru/noties/markwon/il/AsyncDrawableLoader.java @@ -0,0 +1,298 @@ +package ru.noties.markwon.il; + +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.os.Handler; +import android.os.Looper; +import android.support.annotation.NonNull; +import android.text.TextUtils; + +import com.caverock.androidsvg.SVG; +import com.caverock.androidsvg.SVGParseException; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.ref.WeakReference; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; + +import okhttp3.Call; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; +import pl.droidsonroids.gif.GifDrawable; +import ru.noties.debug.Debug; +import ru.noties.markwon.spans.AsyncDrawable; + +public class AsyncDrawableLoader implements AsyncDrawable.Loader { + + public static AsyncDrawableLoader create() { + return builder().build(); + } + + public static AsyncDrawableLoader.Builder builder() { + return new Builder(); + } + + private static final String HEADER_CONTENT_TYPE = "Content-Type"; + private static final String CONTENT_TYPE_SVG = "image/svg+xml"; + private static final String CONTENT_TYPE_GIF = "image/gif"; + + private final OkHttpClient client; + private final Resources resources; + private final ExecutorService executorService; + private final Handler mainThread; + private final Drawable errorDrawable; + + private final Map<String, Future<?>> requests; + + AsyncDrawableLoader(Builder builder) { + this.client = builder.client; + this.resources = builder.resources; + this.executorService = builder.executorService; + this.mainThread = new Handler(Looper.getMainLooper()); + this.errorDrawable = builder.errorDrawable; + this.requests = new HashMap<>(3); + } + + + @Override + public void load(@NonNull String destination, @NonNull AsyncDrawable drawable) { + // if drawable is not a link -> show loading placeholder... + requests.put(destination, execute(destination, drawable)); + } + + @Override + public void cancel(@NonNull String destination) { + + final Future<?> request = requests.remove(destination); + if (request != null) { + request.cancel(true); + } + + final List<Call> calls = client.dispatcher().queuedCalls(); + if (calls != null) { + for (Call call : calls) { + if (!call.isCanceled()) { + if (destination.equals(call.request().tag())) { + call.cancel(); + } + } + } + } + } + + private Future<?> execute(@NonNull final String destination, @NonNull AsyncDrawable drawable) { + final WeakReference<AsyncDrawable> reference = new WeakReference<AsyncDrawable>(drawable); + // todo, if not a link -> show placeholder + return executorService.submit(new Runnable() { + @Override + public void run() { + + final Request request = new Request.Builder() + .url(destination) + .tag(destination) + .build(); + + Response response = null; + try { + response = client.newCall(request).execute(); + } catch (IOException e) { + e.printStackTrace(); + } + + Debug.i(destination, response); + + Drawable result = null; + + if (response != null) { + + final ResponseBody body = response.body(); + if (body != null) { + final InputStream inputStream = body.byteStream(); + if (inputStream != null) { + final String contentType = response.header(HEADER_CONTENT_TYPE); + try { + // svg can have `image/svg+xml;charset=...` + if (CONTENT_TYPE_SVG.equals(contentType) + || (!TextUtils.isEmpty(contentType) && contentType.startsWith(CONTENT_TYPE_SVG))) { + // handle SVG + result = handleSvg(inputStream); + } else if (CONTENT_TYPE_GIF.equals(contentType)) { + // handle gif + result = handleGif(inputStream); + } else { + result = handleSimple(inputStream); + // just try to decode whatever it is + } + } finally { + try { + inputStream.close(); + } catch (IOException e) { + // no op + } + } + } + } + } + + // if result is null, we assume it's an error + if (result == null) { + result = errorDrawable; + } + + if (result != null) { + final Drawable out = result; + mainThread.post(new Runnable() { + @Override + public void run() { + final AsyncDrawable asyncDrawable = reference.get(); + if (asyncDrawable != null && asyncDrawable.isAttached()) { + asyncDrawable.setResult(out); + } + } + }); + } + + requests.remove(destination); + } + }); + } + + private Drawable handleSvg(InputStream stream) { + + final Drawable out; + + SVG svg = null; + try { + svg = SVG.getFromInputStream(stream); + } catch (SVGParseException e) { + e.printStackTrace(); + } + + if (svg == null) { + out = null; + } else { + + 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); + + out = new BitmapDrawable(resources, bitmap); + DrawableUtils.intrinsicBounds(out); + } + + return out; + } + + private Drawable handleGif(InputStream stream) { + + Drawable out = null; + + final byte[] bytes = readBytes(stream); + if (bytes != null) { + try { + out = new GifDrawable(bytes); + DrawableUtils.intrinsicBounds(out); + } catch (IOException e) { + e.printStackTrace(); + } + } + + return out; + } + + private Drawable handleSimple(InputStream stream) { + + final Drawable out; + + final Bitmap bitmap = BitmapFactory.decodeStream(stream); + if (bitmap != null) { + out = new BitmapDrawable(resources, bitmap); + DrawableUtils.intrinsicBounds(out); + } else { + out = null; + } + + return out; + } + + private static byte[] readBytes(InputStream stream) { + + byte[] out = null; + + try { + 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); + } + out = outputStream.toByteArray(); + } catch (IOException e) { + e.printStackTrace(); + } + + return out; + } + + public static class Builder { + + private OkHttpClient client; + private Resources resources; + private ExecutorService executorService; + private Drawable errorDrawable; + + public Builder client(@NonNull OkHttpClient client) { + this.client = client; + return this; + } + + public Builder resources(@NonNull Resources resources) { + this.resources = resources; + return this; + } + + public Builder executorService(ExecutorService executorService) { + this.executorService = executorService; + return this; + } + + public Builder errorDrawable(Drawable errorDrawable) { + this.errorDrawable = errorDrawable; + return this; + } + + public AsyncDrawableLoader build() { + if (client == null) { + client = new OkHttpClient(); + } + if (resources == null) { + resources = Resources.getSystem(); + } + if (executorService == null) { + // we will use executor from okHttp + executorService = client.dispatcher().executorService(); + } + return new AsyncDrawableLoader(this); + } + } +} diff --git a/library-image-loader/src/main/java/ru/noties/markwon/il/DrawableUtils.java b/library-image-loader/src/main/java/ru/noties/markwon/il/DrawableUtils.java new file mode 100644 index 00000000..f2aef636 --- /dev/null +++ b/library-image-loader/src/main/java/ru/noties/markwon/il/DrawableUtils.java @@ -0,0 +1,13 @@ +package ru.noties.markwon.il; + +import android.graphics.drawable.Drawable; +import android.support.annotation.NonNull; + +abstract class DrawableUtils { + + static void intrinsicBounds(@NonNull Drawable drawable) { + drawable.setBounds(0, 0, drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight()); + } + + private DrawableUtils() {} +} diff --git a/library-renderer/src/main/java/ru/noties/markwon/DrawablesScheduler.java b/library-renderer/src/main/java/ru/noties/markwon/DrawablesScheduler.java index 7defb361..bdb50024 100644 --- a/library-renderer/src/main/java/ru/noties/markwon/DrawablesScheduler.java +++ b/library-renderer/src/main/java/ru/noties/markwon/DrawablesScheduler.java @@ -14,7 +14,6 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; -import ru.noties.debug.Debug; import ru.noties.markwon.spans.AsyncDrawable; import ru.noties.markwon.spans.AsyncDrawableSpan; @@ -22,7 +21,7 @@ abstract class DrawablesScheduler { static void schedule(@NonNull final TextView textView) { - final List<Pair> list = extract(textView, true); + final List<AsyncDrawable> list = extract(textView); if (list.size() > 0) { textView.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener() { @Override @@ -37,22 +36,22 @@ abstract class DrawablesScheduler { } }); - for (Pair pair : list) { - pair.drawable.setCallback2(new DrawableCallbackImpl(textView, pair.coordinatesProvider, pair.drawable.getBounds())); + for (AsyncDrawable drawable : list) { + drawable.setCallback2(new DrawableCallbackImpl(textView, drawable.getBounds())); } } } // must be called when text manually changed in TextView static void unschedule(@NonNull TextView view) { - for (Pair pair : extract(view, false)) { - pair.drawable.setCallback2(null); + for (AsyncDrawable drawable : extract(view)) { + drawable.setCallback2(null); } } - private static List<Pair> extract(@NonNull TextView view, boolean coordinates) { + private static List<AsyncDrawable> extract(@NonNull TextView view) { - final List<Pair> list; + final List<AsyncDrawable> list; final CharSequence cs = view.getText(); final int length = cs != null @@ -74,18 +73,14 @@ abstract class DrawablesScheduler { if (span instanceof AsyncDrawableSpan) { final AsyncDrawableSpan asyncDrawableSpan = (AsyncDrawableSpan) span; - final CoordinatesProvider provider = coordinates - ? new AsyncDrawableSpanCoordinatesProvider(asyncDrawableSpan) - : null; - - list.add(new Pair(asyncDrawableSpan.getDrawable(), provider)); + list.add(asyncDrawableSpan.getDrawable()); } else if (span instanceof DynamicDrawableSpan) { // it's really not optimal thing because it stores Drawable in WeakReference... // which is why it will be most likely already de-referenced... final Drawable d = ((DynamicDrawableSpan) span).getDrawable(); if (d != null && d instanceof AsyncDrawable) { - list.add(new Pair((AsyncDrawable) d, null)); + list.add((AsyncDrawable) d); } } } @@ -101,21 +96,13 @@ abstract class DrawablesScheduler { private DrawablesScheduler() { } - private interface CoordinatesProvider { - int getX(); - - int getY(); - } - private static class DrawableCallbackImpl implements Drawable.Callback { private final TextView view; - private final CoordinatesProvider coordinatesProvider; private Rect previousBounds; - DrawableCallbackImpl(TextView view, CoordinatesProvider provider, Rect initialBounds) { + DrawableCallbackImpl(TextView view, Rect initialBounds) { this.view = view; - this.coordinatesProvider = provider; this.previousBounds = new Rect(initialBounds); } @@ -134,7 +121,7 @@ abstract class DrawablesScheduler { final Rect rect = who.getBounds(); - // okay... teh thing is IF we do not change bounds size, normal invalidate would do + // okay... the thing is IF we do not change bounds size, normal invalidate would do // but if the size has changed, then we need to update the whole layout... if (!previousBounds.equals(rect)) { @@ -143,29 +130,7 @@ abstract class DrawablesScheduler { previousBounds = new Rect(rect); } else { - // if bounds are the same then simple invalidate would do - - if (coordinatesProvider != null) { - final int x = coordinatesProvider.getX(); - final int y = coordinatesProvider.getY(); - view.postInvalidate( - x + rect.left, - y + rect.top, - x + rect.right, - y + rect.bottom - ); - Debug.i(x + rect.left, - y + rect.top, - x + rect.right, - y + rect.bottom); - } else { - Debug.i(); - // else all we can do is request full re-draw... maybe system is smart enough not re-draw what is not on screen? - view.postInvalidate(); -// we do not need to invalidate if, for example, a gif is playing somewhere out of current viewPort... -// but i do not see... - } -// view.postInvalidate(); + view.postInvalidate(); } } @@ -180,33 +145,4 @@ abstract class DrawablesScheduler { view.removeCallbacks(what); } } - - private static class AsyncDrawableSpanCoordinatesProvider implements CoordinatesProvider { - - private final AsyncDrawableSpan span; - - private AsyncDrawableSpanCoordinatesProvider(AsyncDrawableSpan span) { - this.span = span; - } - - @Override - public int getX() { - return span.lastKnownDrawX(); - } - - @Override - public int getY() { - return span.lastKnownDrawY(); - } - } - - private static class Pair { - final AsyncDrawable drawable; - final CoordinatesProvider coordinatesProvider; - - Pair(AsyncDrawable drawable, CoordinatesProvider coordinatesProvider) { - this.drawable = drawable; - this.coordinatesProvider = coordinatesProvider; - } - } } diff --git a/library-renderer/src/main/java/ru/noties/markwon/LinkResolverDef.java b/library-renderer/src/main/java/ru/noties/markwon/LinkResolverDef.java index 2ca4de56..109af717 100644 --- a/library-renderer/src/main/java/ru/noties/markwon/LinkResolverDef.java +++ b/library-renderer/src/main/java/ru/noties/markwon/LinkResolverDef.java @@ -11,7 +11,7 @@ import android.view.View; import ru.noties.markwon.spans.LinkSpan; -class LinkResolverDef implements LinkSpan.Resolver { +public class LinkResolverDef implements LinkSpan.Resolver { @Override public void resolve(View view, @NonNull String link) { final Uri uri = Uri.parse(link); diff --git a/library-renderer/src/main/java/ru/noties/markwon/SpannableConfiguration.java b/library-renderer/src/main/java/ru/noties/markwon/SpannableConfiguration.java index 024c8fa0..4952b599 100644 --- a/library-renderer/src/main/java/ru/noties/markwon/SpannableConfiguration.java +++ b/library-renderer/src/main/java/ru/noties/markwon/SpannableConfiguration.java @@ -121,7 +121,7 @@ public class SpannableConfiguration { urlProcessor = new UrlProcessorNoOp(); } if (htmlParser == null) { - htmlParser = SpannableHtmlParser.create(theme, asyncDrawableLoader, urlProcessor); + htmlParser = SpannableHtmlParser.create(theme, asyncDrawableLoader, urlProcessor, linkResolver); } return new SpannableConfiguration(this); } diff --git a/library-renderer/src/main/java/ru/noties/markwon/UrlProcessorRelativeToAbsolute.java b/library-renderer/src/main/java/ru/noties/markwon/UrlProcessorRelativeToAbsolute.java index bae6fcb6..fcfd4cab 100644 --- a/library-renderer/src/main/java/ru/noties/markwon/UrlProcessorRelativeToAbsolute.java +++ b/library-renderer/src/main/java/ru/noties/markwon/UrlProcessorRelativeToAbsolute.java @@ -6,6 +6,7 @@ import android.support.annotation.Nullable; import java.net.MalformedURLException; import java.net.URL; +@SuppressWarnings("WeakerAccess") public class UrlProcessorRelativeToAbsolute implements UrlProcessor { private final URL base; diff --git a/library-renderer/src/main/java/ru/noties/markwon/renderer/SpannableMarkdownVisitor.java b/library-renderer/src/main/java/ru/noties/markwon/renderer/SpannableMarkdownVisitor.java index 656f1087..70fef6a6 100644 --- a/library-renderer/src/main/java/ru/noties/markwon/renderer/SpannableMarkdownVisitor.java +++ b/library-renderer/src/main/java/ru/noties/markwon/renderer/SpannableMarkdownVisitor.java @@ -33,7 +33,6 @@ import org.commonmark.node.ThematicBreak; import java.util.ArrayDeque; import java.util.Deque; -import ru.noties.debug.Debug; import ru.noties.markwon.SpannableConfiguration; import ru.noties.markwon.renderer.html.SpannableHtmlParser; import ru.noties.markwon.spans.AsyncDrawable; @@ -51,8 +50,6 @@ import ru.noties.markwon.spans.ThematicBreakSpan; @SuppressWarnings("WeakerAccess") public class SpannableMarkdownVisitor extends AbstractVisitor { - private static final String HTML_CONTENT = "<%1$s>%2$s</%3$s>"; - private final SpannableConfiguration configuration; private final SpannableStringBuilder builder; private final Deque<HtmlInlineItem> htmlInlineItems; @@ -302,7 +299,7 @@ public class SpannableMarkdownVisitor extends AbstractVisitor { // we must check if anything _was_ added, as we need at least one char to render if (length == builder.length()) { - builder.append(' '); // breakable space + builder.append('\uFFFC'); } final Node parent = image.getParent(); @@ -321,17 +318,23 @@ public class SpannableMarkdownVisitor extends AbstractVisitor { link ) ); + + // todo, maybe, if image is not inside a link, we should make it clickable, so + // user can open it in external viewer? } @Override public void visit(HtmlBlock htmlBlock) { // http://spec.commonmark.org/0.18/#html-blocks - Debug.i(htmlBlock, htmlBlock.getLiteral()); - super.visit(htmlBlock); + final Spanned spanned = configuration.htmlParser().getSpanned(null, htmlBlock.getLiteral()); + if (!TextUtils.isEmpty(spanned)) { + builder.append(spanned); + } } @Override public void visit(HtmlInline htmlInline) { + final SpannableHtmlParser htmlParser = configuration.htmlParser(); final SpannableHtmlParser.Tag tag = htmlParser.parseTag(htmlInline.getLiteral()); @@ -340,37 +343,25 @@ public class SpannableMarkdownVisitor extends AbstractVisitor { final boolean voidTag = tag.voidTag(); if (!voidTag && tag.opening()) { // push in stack - htmlInlineItems.push(new HtmlInlineItem(tag.name(), builder.length())); + htmlInlineItems.push(new HtmlInlineItem(tag, builder.length())); visitChildren(htmlInline); } else { if (!voidTag) { if (htmlInlineItems.size() > 0) { final HtmlInlineItem item = htmlInlineItems.pop(); - final Object span = htmlParser.handleTag(item.tag); - final int start = item.start; + final Object span = htmlParser.getSpanForTag(item.tag); if (span != null) { setSpan(item.start, span); - } else { - final String content = builder.subSequence(start, builder.length()).toString(); - final String html = String.format(HTML_CONTENT, item.tag, content, tag.name()); - final Object[] spans = htmlParser.htmlSpans(html); - final int length = spans != null - ? spans.length - : 0; - for (int i = 0; i < length; i++) { - setSpan(start, spans[i]); - } } } } else { - final String content = htmlInline.getLiteral(); - if (!TextUtils.isEmpty(content)) { - final Spanned html = htmlParser.html(content); - if (!TextUtils.isEmpty(html)) { - builder.append(html); - } + + final Spanned html = htmlParser.getSpanned(tag, htmlInline.getLiteral()); + if (!TextUtils.isEmpty(html)) { + builder.append(html); } + } } } else { @@ -412,10 +403,11 @@ public class SpannableMarkdownVisitor extends AbstractVisitor { } private static class HtmlInlineItem { - final String tag; + + final SpannableHtmlParser.Tag tag; final int start; - HtmlInlineItem(String tag, int start) { + HtmlInlineItem(SpannableHtmlParser.Tag tag, int start) { this.tag = tag; this.start = start; } diff --git a/library-renderer/src/main/java/ru/noties/markwon/renderer/html/BoldProvider.java b/library-renderer/src/main/java/ru/noties/markwon/renderer/html/BoldProvider.java index be89a777..7613b8b4 100644 --- a/library-renderer/src/main/java/ru/noties/markwon/renderer/html/BoldProvider.java +++ b/library-renderer/src/main/java/ru/noties/markwon/renderer/html/BoldProvider.java @@ -1,10 +1,13 @@ package ru.noties.markwon.renderer.html; +import android.support.annotation.NonNull; + import ru.noties.markwon.spans.StrongEmphasisSpan; class BoldProvider implements SpannableHtmlParser.SpanProvider { + @Override - public Object provide() { + public Object provide(@NonNull SpannableHtmlParser.Tag tag) { return new StrongEmphasisSpan(); } } diff --git a/library-renderer/src/main/java/ru/noties/markwon/renderer/html/HtmlImageGetter.java b/library-renderer/src/main/java/ru/noties/markwon/renderer/html/HtmlImageGetter.java deleted file mode 100644 index 1469a4f1..00000000 --- a/library-renderer/src/main/java/ru/noties/markwon/renderer/html/HtmlImageGetter.java +++ /dev/null @@ -1,31 +0,0 @@ -package ru.noties.markwon.renderer.html; - -import android.graphics.drawable.Drawable; -import android.support.annotation.NonNull; -import android.support.annotation.Nullable; -import android.text.Html; - -import ru.noties.markwon.UrlProcessor; -import ru.noties.markwon.spans.AsyncDrawable; - -class HtmlImageGetter implements Html.ImageGetter { - - private final AsyncDrawable.Loader loader; - private final UrlProcessor urlProcessor; - - HtmlImageGetter(@NonNull AsyncDrawable.Loader loader, @Nullable UrlProcessor urlProcessor) { - this.loader = loader; - this.urlProcessor = urlProcessor; - } - - @Override - public Drawable getDrawable(String source) { - final String destination; - if (urlProcessor == null) { - destination = source; - } else { - destination = urlProcessor.process(source); - } - return new AsyncDrawable(destination, loader); - } -} diff --git a/library-renderer/src/main/java/ru/noties/markwon/renderer/html/ImageProviderImpl.java b/library-renderer/src/main/java/ru/noties/markwon/renderer/html/ImageProviderImpl.java new file mode 100644 index 00000000..3e45caee --- /dev/null +++ b/library-renderer/src/main/java/ru/noties/markwon/renderer/html/ImageProviderImpl.java @@ -0,0 +1,63 @@ +package ru.noties.markwon.renderer.html; + +import android.support.annotation.NonNull; +import android.text.SpannableString; +import android.text.Spanned; +import android.text.TextUtils; + +import java.util.Map; + +import ru.noties.markwon.UrlProcessor; +import ru.noties.markwon.spans.AsyncDrawable; +import ru.noties.markwon.spans.AsyncDrawableSpan; +import ru.noties.markwon.spans.SpannableTheme; + +class ImageProviderImpl implements SpannableHtmlParser.ImageProvider { + + private final SpannableTheme theme; + private final AsyncDrawable.Loader loader; + private final UrlProcessor urlProcessor; + + ImageProviderImpl( + @NonNull SpannableTheme theme, + @NonNull AsyncDrawable.Loader loader, + @NonNull UrlProcessor urlProcessor) { + this.theme = theme; + this.loader = loader; + this.urlProcessor = urlProcessor; + } + + @Override + public Spanned provide(@NonNull SpannableHtmlParser.Tag tag) { + + final Spanned spanned; + + final Map<String, String> attributes = tag.attributes(); + final String src = attributes.get("src"); + final String alt = attributes.get("alt"); + + if (!TextUtils.isEmpty(src)) { + + final String destination = urlProcessor.process(src); + + final String replacement; + if (!TextUtils.isEmpty(alt)) { + replacement = alt; + } else { + replacement = "\uFFFC"; + } + + final AsyncDrawable drawable = new AsyncDrawable(destination, loader); + final AsyncDrawableSpan span = new AsyncDrawableSpan(theme, drawable); + + final SpannableString string = new SpannableString(replacement); + string.setSpan(span, 0, string.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + spanned = string; + } else { + spanned = null; + } + + return spanned; + } +} diff --git a/library-renderer/src/main/java/ru/noties/markwon/renderer/html/ItalicsProvider.java b/library-renderer/src/main/java/ru/noties/markwon/renderer/html/ItalicsProvider.java index e4a47bae..3fd7f068 100644 --- a/library-renderer/src/main/java/ru/noties/markwon/renderer/html/ItalicsProvider.java +++ b/library-renderer/src/main/java/ru/noties/markwon/renderer/html/ItalicsProvider.java @@ -1,10 +1,13 @@ package ru.noties.markwon.renderer.html; +import android.support.annotation.NonNull; + import ru.noties.markwon.spans.EmphasisSpan; class ItalicsProvider implements SpannableHtmlParser.SpanProvider { + @Override - public Object provide() { + public Object provide(@NonNull SpannableHtmlParser.Tag tag) { return new EmphasisSpan(); } } diff --git a/library-renderer/src/main/java/ru/noties/markwon/renderer/html/LinkProvider.java b/library-renderer/src/main/java/ru/noties/markwon/renderer/html/LinkProvider.java new file mode 100644 index 00000000..b85456f6 --- /dev/null +++ b/library-renderer/src/main/java/ru/noties/markwon/renderer/html/LinkProvider.java @@ -0,0 +1,45 @@ +package ru.noties.markwon.renderer.html; + +import android.support.annotation.NonNull; +import android.text.TextUtils; + +import java.util.Map; + +import ru.noties.markwon.UrlProcessor; +import ru.noties.markwon.spans.LinkSpan; +import ru.noties.markwon.spans.SpannableTheme; + +class LinkProvider implements SpannableHtmlParser.SpanProvider { + + private final SpannableTheme theme; + private final UrlProcessor urlProcessor; + private final LinkSpan.Resolver resolver; + + LinkProvider( + @NonNull SpannableTheme theme, + @NonNull UrlProcessor urlProcessor, + @NonNull LinkSpan.Resolver resolver) { + this.theme = theme; + this.urlProcessor = urlProcessor; + this.resolver = resolver; + } + + @Override + public Object provide(@NonNull SpannableHtmlParser.Tag tag) { + + final Object span; + + final Map<String, String> attributes = tag.attributes(); + final String href = attributes.get("href"); + if (!TextUtils.isEmpty(href)) { + + final String destination = urlProcessor.process(href); + span = new LinkSpan(theme, destination, resolver); + + } else { + span = null; + } + + return span; + } +} diff --git a/library-renderer/src/main/java/ru/noties/markwon/renderer/html/SpannableHtmlParser.java b/library-renderer/src/main/java/ru/noties/markwon/renderer/html/SpannableHtmlParser.java index 6a6e577a..74df59a1 100644 --- a/library-renderer/src/main/java/ru/noties/markwon/renderer/html/SpannableHtmlParser.java +++ b/library-renderer/src/main/java/ru/noties/markwon/renderer/html/SpannableHtmlParser.java @@ -7,37 +7,35 @@ import android.support.annotation.Nullable; import android.text.Html; import android.text.Spanned; -import java.util.Collections; import java.util.HashMap; -import java.util.HashSet; import java.util.Map; -import java.util.Set; -import ru.noties.debug.Debug; +import ru.noties.markwon.LinkResolverDef; import ru.noties.markwon.UrlProcessor; +import ru.noties.markwon.UrlProcessorNoOp; import ru.noties.markwon.spans.AsyncDrawable; +import ru.noties.markwon.spans.LinkSpan; import ru.noties.markwon.spans.SpannableTheme; @SuppressWarnings("WeakerAccess") public class SpannableHtmlParser { - // we need to handle images independently (in order to parse alt, width, height, etc) - // creates default parser public static SpannableHtmlParser create( @NonNull SpannableTheme theme, @NonNull AsyncDrawable.Loader loader ) { - return builderWithDefaults(theme, loader, null) + return builderWithDefaults(theme, loader, null, null) .build(); } public static SpannableHtmlParser create( @NonNull SpannableTheme theme, @NonNull AsyncDrawable.Loader loader, - @NonNull UrlProcessor urlProcessor + @NonNull UrlProcessor urlProcessor, + @NonNull LinkSpan.Resolver resolver ) { - return builderWithDefaults(theme, loader, urlProcessor) + return builderWithDefaults(theme, loader, urlProcessor, resolver) .build(); } @@ -45,151 +43,133 @@ public class SpannableHtmlParser { return new Builder(); } + public static Builder builderWithDefaults(@NonNull SpannableTheme theme) { + return builderWithDefaults(theme, null, null, null); + } + public static Builder builderWithDefaults( @NonNull SpannableTheme theme, @Nullable AsyncDrawable.Loader asyncDrawableLoader, - @Nullable UrlProcessor urlProcessor + @Nullable UrlProcessor urlProcessor, + @Nullable LinkSpan.Resolver resolver ) { + if (urlProcessor == null) { + urlProcessor = new UrlProcessorNoOp(); + } + + if (resolver == null) { + resolver = new LinkResolverDef(); + } + final BoldProvider boldProvider = new BoldProvider(); final ItalicsProvider italicsProvider = new ItalicsProvider(); final StrikeProvider strikeProvider = new StrikeProvider(); - final HtmlParser parser; + final ImageProvider imageProvider; if (asyncDrawableLoader != null) { - parser = DefaultHtmlParser.create(new HtmlImageGetter(asyncDrawableLoader, urlProcessor), null); + imageProvider = new ImageProviderImpl(theme, asyncDrawableLoader, urlProcessor); } else { - parser = DefaultHtmlParser.create(null, null); + imageProvider = null; } return new Builder() - .customTag("b", boldProvider) - .customTag("strong", boldProvider) - .customTag("i", italicsProvider) - .customTag("em", italicsProvider) - .customTag("cite", italicsProvider) - .customTag("dfn", italicsProvider) - .customTag("sup", new SuperScriptProvider(theme)) - .customTag("sub", new SubScriptProvider(theme)) - .customTag("u", new UnderlineProvider()) - .customTag("del", strikeProvider) - .customTag("s", strikeProvider) - .customTag("strike", strikeProvider) - .parser(parser); + .simpleTag("b", boldProvider) + .simpleTag("strong", boldProvider) + .simpleTag("i", italicsProvider) + .simpleTag("em", italicsProvider) + .simpleTag("cite", italicsProvider) + .simpleTag("dfn", italicsProvider) + .simpleTag("sup", new SuperScriptProvider(theme)) + .simpleTag("sub", new SubScriptProvider(theme)) + .simpleTag("u", new UnderlineProvider()) + .simpleTag("del", strikeProvider) + .simpleTag("s", strikeProvider) + .simpleTag("strike", strikeProvider) + .simpleTag("a", new LinkProvider(theme, urlProcessor, resolver)) + .imageProvider(imageProvider); } // for simple tags without arguments // <b>, <i>, etc public interface SpanProvider { - Object provide(); + Object provide(@NonNull Tag tag); + } + + public interface ImageProvider { + Spanned provide(@NonNull Tag tag); } public interface HtmlParser { - Object[] getSpans(@NonNull String html); + + // returns span for a simple content + Object getSpan(@NonNull String html); Spanned parse(@NonNull String html); } - private static final String LINK_START = "<a "; - - private final Map<String, SpanProvider> customTags; - private final Set<String> voidTags; + private final Map<String, SpanProvider> simpleTags; + private final ImageProvider imageProvider; private final HtmlParser parser; + private final TagParser tagParser; private SpannableHtmlParser(Builder builder) { - this.customTags = builder.customTags; - this.voidTags = voidTags(); + this.simpleTags = builder.simpleTags; + this.imageProvider = builder.imageProvider; this.parser = builder.parser; + this.tagParser = new TagParser(); } @Nullable public Tag parseTag(String html) { - - final Tag tag; - - final int length = html != null - ? html.length() - : 0; - - // absolutely minimum (`<i>`) - if (length < 3) { - tag = null; - } else { - // okay, we will consider a tag a void one if it's in our void list tag - final boolean closing = '<' == html.charAt(0) && '/' == html.charAt(1); - final boolean voidTag; - if (closing) { - voidTag = false; - } else { - int firstNonChar = -1; - for (int i = 1; i < length; i++) { - if (!Character.isLetterOrDigit(html.charAt(i))) { - firstNonChar = i; - break; - } - } - if (firstNonChar > 1) { - final String name = html.substring(1, firstNonChar); - voidTag = voidTags.contains(name); - } else { - voidTag = false; - } - } - - // todo, we do not strip to void tag name, so it can be possibly ended with `/` - final String name = closing - ? html.substring(2, length - 1) - : html.substring(1, length - 1); - - tag = new Tag(name, !closing, voidTag); - } - - return tag; + return tagParser.parse(html); } @Nullable - public Object handleTag(String tag) { + public Object getSpanForTag(@NonNull Tag tag) { + + // check if we have specific handler for tag.name + final Object out; - final SpanProvider provider = customTags.get(tag); + + final SpanProvider provider = simpleTags.get(tag.name); if (provider != null) { - out = provider.provide(); + out = provider.provide(tag); } else { - out = null; + // let's prepare mock content & extract spans from it + // actual content doesn't matter, here it's just `abc` + final String mock = tag.raw + "abc" + "</" + tag.name + ">"; + out = parser.getSpan(mock); } + return out; } - @Nullable - public Object[] htmlSpans(String html) { - // todo, additional handling of: image & link - Debug.i("html: %s", html); - return parser.getSpans(html); - } - - // this is called when we encounter `void` tag - // `img` is a void tag - public Spanned html(String html) { - Debug.i("html: %s", html); - return parser.parse(html); - } - - private static Set<String> voidTags() { - final String[] tags = { - "area", "base", "br", "col", "embed", "hr", "img", "input", - "keygen", "link", "meta", "param", "source", "track", "wbr" - }; - final Set<String> set = new HashSet<>(tags.length); - Collections.addAll(set, tags); - return set; + // if tag is NULL, then it's HtmlBlock... else just a void tag + public Spanned getSpanned(@Nullable Tag tag, String html) { + final Spanned spanned; + if (tag != null && "img".equals(tag.name) && imageProvider != null) { + spanned = imageProvider.provide(tag); + } else { + spanned = parser.parse(html); + } + return spanned; } public static class Builder { - private final Map<String, SpanProvider> customTags = new HashMap<>(3); + private final Map<String, SpanProvider> simpleTags = new HashMap<>(3); + + private ImageProvider imageProvider; private HtmlParser parser; - public Builder customTag(@NonNull String tag, @NonNull SpanProvider provider) { - customTags.put(tag, provider); + public Builder simpleTag(@NonNull String tag, @NonNull SpanProvider provider) { + simpleTags.put(tag, provider); + return this; + } + + public Builder imageProvider(ImageProvider imageProvider) { + this.imageProvider = imageProvider; return this; } @@ -200,7 +180,7 @@ public class SpannableHtmlParser { public SpannableHtmlParser build() { if (parser == null) { - parser = DefaultHtmlParser.create(null, null); + parser = DefaultHtmlParser.create(); } return new SpannableHtmlParser(this); } @@ -208,20 +188,34 @@ public class SpannableHtmlParser { public static class Tag { + private final String raw; private final String name; + private final Map<String, String> attributes; + private final boolean opening; private final boolean voidTag; - public Tag(String name, boolean opening, boolean voidTag) { + public Tag(String raw, String name, @NonNull Map<String, String> attributes, boolean opening, boolean voidTag) { + this.raw = raw; this.name = name; + this.attributes = attributes; this.opening = opening; this.voidTag = voidTag; } + public String raw() { + return raw; + } + public String name() { return name; } + @NonNull + public Map<String, String> attributes() { + return attributes; + } + public boolean opening() { return opening; } @@ -233,7 +227,9 @@ public class SpannableHtmlParser { @Override public String toString() { return "Tag{" + - "name='" + name + '\'' + + "raw='" + raw + '\'' + + ", name='" + name + '\'' + + ", attributes=" + attributes + ", opening=" + opening + ", voidTag=" + voidTag + '}'; @@ -242,25 +238,20 @@ public class SpannableHtmlParser { public static abstract class DefaultHtmlParser implements HtmlParser { - public static DefaultHtmlParser create(@Nullable Html.ImageGetter imageGetter, @Nullable Html.TagHandler tagHandler) { + public static DefaultHtmlParser create() { final DefaultHtmlParser parser; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - parser = new Parser24(imageGetter, tagHandler); + parser = new Parser24(); } else { - parser = new ParserPre24(imageGetter, tagHandler); + parser = new ParserPre24(); } return parser; } - final Html.ImageGetter imageGetter; - final Html.TagHandler tagHandler; + Object getSpan(Spanned spanned) { - DefaultHtmlParser(Html.ImageGetter imageGetter, Html.TagHandler tagHandler) { - this.imageGetter = imageGetter; - this.tagHandler = tagHandler; - } + final Object out; - Object[] getSpans(Spanned spanned) { final Object[] spans; final int length = spanned != null ? spanned.length() : 0; if (length == 0) { @@ -268,42 +259,42 @@ public class SpannableHtmlParser { } else { spans = spanned.getSpans(0, length, Object.class); } - return spans; + + if (spans != null + && spans.length > 0) { + out = spans[0]; + } else { + out = null; + } + + return out; } @SuppressWarnings("deprecation") private static class ParserPre24 extends DefaultHtmlParser { - ParserPre24(Html.ImageGetter imageGetter, Html.TagHandler tagHandler) { - super(imageGetter, tagHandler); - } - @Override - public Object[] getSpans(@NonNull String html) { - return getSpans(parse(html)); + public Object getSpan(@NonNull String html) { + return getSpan(parse(html)); } @Override public Spanned parse(@NonNull String html) { - return Html.fromHtml(html, imageGetter, tagHandler); + return Html.fromHtml(html, null, null); } } @TargetApi(Build.VERSION_CODES.N) private static class Parser24 extends DefaultHtmlParser { - Parser24(Html.ImageGetter imageGetter, Html.TagHandler tagHandler) { - super(imageGetter, tagHandler); - } - @Override - public Object[] getSpans(@NonNull String html) { - return getSpans(parse(html)); + public Object getSpan(@NonNull String html) { + return getSpan(parse(html)); } @Override public Spanned parse(@NonNull String html) { - return Html.fromHtml(html, Html.FROM_HTML_MODE_COMPACT, imageGetter, tagHandler); + return Html.fromHtml(html, Html.FROM_HTML_MODE_COMPACT, null, null); } } } diff --git a/library-renderer/src/main/java/ru/noties/markwon/renderer/html/StrikeProvider.java b/library-renderer/src/main/java/ru/noties/markwon/renderer/html/StrikeProvider.java index d4690440..df01f9e6 100644 --- a/library-renderer/src/main/java/ru/noties/markwon/renderer/html/StrikeProvider.java +++ b/library-renderer/src/main/java/ru/noties/markwon/renderer/html/StrikeProvider.java @@ -1,10 +1,11 @@ package ru.noties.markwon.renderer.html; +import android.support.annotation.NonNull; import android.text.style.StrikethroughSpan; class StrikeProvider implements SpannableHtmlParser.SpanProvider { @Override - public Object provide() { + public Object provide(@NonNull SpannableHtmlParser.Tag tag) { return new StrikethroughSpan(); } } diff --git a/library-renderer/src/main/java/ru/noties/markwon/renderer/html/SubScriptProvider.java b/library-renderer/src/main/java/ru/noties/markwon/renderer/html/SubScriptProvider.java index 13299404..920ff01e 100644 --- a/library-renderer/src/main/java/ru/noties/markwon/renderer/html/SubScriptProvider.java +++ b/library-renderer/src/main/java/ru/noties/markwon/renderer/html/SubScriptProvider.java @@ -1,5 +1,7 @@ package ru.noties.markwon.renderer.html; +import android.support.annotation.NonNull; + import ru.noties.markwon.spans.SpannableTheme; import ru.noties.markwon.spans.SubScriptSpan; @@ -12,7 +14,7 @@ class SubScriptProvider implements SpannableHtmlParser.SpanProvider { } @Override - public Object provide() { + public Object provide(@NonNull SpannableHtmlParser.Tag tag) { return new SubScriptSpan(theme); } } diff --git a/library-renderer/src/main/java/ru/noties/markwon/renderer/html/SuperScriptProvider.java b/library-renderer/src/main/java/ru/noties/markwon/renderer/html/SuperScriptProvider.java index 91202571..4faf9078 100644 --- a/library-renderer/src/main/java/ru/noties/markwon/renderer/html/SuperScriptProvider.java +++ b/library-renderer/src/main/java/ru/noties/markwon/renderer/html/SuperScriptProvider.java @@ -1,5 +1,7 @@ package ru.noties.markwon.renderer.html; +import android.support.annotation.NonNull; + import ru.noties.markwon.spans.SpannableTheme; import ru.noties.markwon.spans.SuperScriptSpan; @@ -12,7 +14,7 @@ class SuperScriptProvider implements SpannableHtmlParser.SpanProvider { } @Override - public Object provide() { + public Object provide(@NonNull SpannableHtmlParser.Tag tag) { return new SuperScriptSpan(theme); } } diff --git a/library-renderer/src/main/java/ru/noties/markwon/renderer/html/TagParser.java b/library-renderer/src/main/java/ru/noties/markwon/renderer/html/TagParser.java new file mode 100644 index 00000000..8d9b57d9 --- /dev/null +++ b/library-renderer/src/main/java/ru/noties/markwon/renderer/html/TagParser.java @@ -0,0 +1,155 @@ +package ru.noties.markwon.renderer.html; + +import android.support.annotation.Nullable; + +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +class TagParser { + + + private static final Set<String> VOID_TAGS; + static { + final String[] tags = { + "area", "base", "br", "col", "embed", "hr", "img", "input", + "keygen", "link", "meta", "param", "source", "track", "wbr" + }; + final Set<String> set = new HashSet<>(tags.length); + Collections.addAll(set, tags); + VOID_TAGS = Collections.unmodifiableSet(set); + } + + + TagParser() { + } + + @Nullable + SpannableHtmlParser.Tag parse(String html) { + + final SpannableHtmlParser.Tag tag; + + final int length = html != null + ? html.length() + : 0; + + // absolutely minimum (`<i>`) + if (length < 3) { + tag = null; + } else { + +// // okay, we will consider a tag a void one if it's in our void list tag + + final boolean closing = '<' == html.charAt(0) && '/' == html.charAt(1); + final boolean voidTag; + + Map<String, String> attributes = null; + + final StringBuilder builder = new StringBuilder(); + + String name = null; + String pendingAttribute = null; + + char c; + char valueDelimiter = '\0'; + + for (int i = 0; i < length; i++) { + + c = html.charAt(i); + + // no more handling + if ('>' == c + || '\\' == c) { + break; + } + + if (name == null) { + if (Character.isSpaceChar(c)) { + //noinspection StatementWithEmptyBody + if (builder.length() == 0) { + // ignore it, we must wait until we have tagName + } else { + + name = builder.toString(); + + // clear buffer + builder.setLength(0); + } + } else { + if (Character.isLetterOrDigit(c)) { + builder.append(c); + } /*else { + // we allow non-letter-digit only if builder.length == 0 + // if we have already started + }*/ + } + } else if (pendingAttribute == null) { + // we start checking for attribute + // ignore non-letter-digits before + if (Character.isLetterOrDigit(c)) { + builder.append(c); + } else /*if ('=' == c)*/ { + + // attribute name is finished (only if we have already added something) + // else it's trailing chars that we are not interested in + if (builder.length() > 0) { + pendingAttribute = builder.toString(); + builder.setLength(0); + } + } + } else { + // first char that we will meet will be the delimiter + if (valueDelimiter == '\0') { + valueDelimiter = c; + } else { + if (c == valueDelimiter) { + if (attributes == null) { + attributes = new HashMap<>(3); + } + attributes.put(pendingAttribute, builder.toString()); + pendingAttribute = null; + valueDelimiter = '\0'; + builder.setLength(0); + } else { + builder.append(c); + } + } + } + } + + if (builder.length() > 0) { + if (name == null) { + name = builder.toString(); + } else if (pendingAttribute != null) { + if (attributes == null) { + attributes = new HashMap<>(3); + } + attributes.put(pendingAttribute, builder.toString()); + } + } + + // in case of wrong parsing + if (name == null) { + tag = null; + } else { + + voidTag = !closing && VOID_TAGS.contains(name); + + final Map<String, String> attributesMap; + if (attributes == null + || attributes.size() == 0) { + //noinspection unchecked + attributesMap = Collections.EMPTY_MAP; + } else { + attributesMap = Collections.unmodifiableMap(attributes); + } + + tag = new SpannableHtmlParser.Tag(html, name, attributesMap, !closing, voidTag); + } + } + + return tag; + } +} diff --git a/library-renderer/src/main/java/ru/noties/markwon/renderer/html/UnderlineProvider.java b/library-renderer/src/main/java/ru/noties/markwon/renderer/html/UnderlineProvider.java index 1d917471..38acc73c 100644 --- a/library-renderer/src/main/java/ru/noties/markwon/renderer/html/UnderlineProvider.java +++ b/library-renderer/src/main/java/ru/noties/markwon/renderer/html/UnderlineProvider.java @@ -1,11 +1,12 @@ package ru.noties.markwon.renderer.html; +import android.support.annotation.NonNull; import android.text.style.UnderlineSpan; class UnderlineProvider implements SpannableHtmlParser.SpanProvider { @Override - public Object provide() { + public Object provide(@NonNull SpannableHtmlParser.Tag tag) { return new UnderlineSpan(); } } diff --git a/settings.gradle b/settings.gradle index 43603efd..fb1c7062 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1 @@ -include ':app', ':library-renderer' +include ':app', ':library-renderer', ':library-image-loader'