/* * Copyright (C) 2014-2018 Mikhail Kulesh * * This program is free software: you can redistribute it and/or modify it under the terms of the GNU * General Public License as published by the Free Software Foundation, either version 3 of the License, * or (at your option) any later version. * * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without * even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. You should have received a copy of the GNU General * Public License along with this program. If not, see <http://www.gnu.org/licenses/>. */ package com.mkulesh.micromath.widgets; import android.annotation.SuppressLint; import android.content.Context; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.Canvas; import android.graphics.ColorFilter; import android.graphics.PorterDuff; import android.graphics.PorterDuffColorFilter; import android.graphics.RectF; import android.net.Uri; import android.os.Bundle; import android.os.Parcelable; import androidx.appcompat.app.AppCompatActivity; import android.util.AttributeSet; import android.util.Base64; import android.util.DisplayMetrics; import android.view.View.OnClickListener; import android.view.View.OnLongClickListener; import android.widget.Toast; import com.caverock.androidsvg.SVG; import com.caverock.androidsvg.SVGParseException; import com.mkulesh.micromath.fman.FileUtils; import com.mkulesh.micromath.formula.FormulaList; import com.mkulesh.micromath.plus.R; import com.mkulesh.micromath.properties.ImageProperties; import com.mkulesh.micromath.utils.CompatUtils; import com.mkulesh.micromath.utils.ViewUtils; import org.xmlpull.v1.XmlPullParser; import org.xmlpull.v1.XmlSerializer; import java.io.BufferedReader; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.util.Locale; public class CustomImageView extends CustomTextView implements OnLongClickListener, OnClickListener { /* * Constants used to save/restore the instance state. */ private static final String STATE_IMAGE_TYPE = "image_type"; private static final String STATE_IMAGE_URI = "image_uri"; private static final String STATE_IMAGE_BITMAP = "image_bitmap"; private static final String STATE_IMAGE_SVG = "image_svg"; private static final String XML_PROP_BIN_ENCODING = "binEncoding"; private static final String XML_PROP_IMAGE_TYPE = "imgType"; private static final String XML_PROP_IMAGE_PNG = "png"; private static final String XML_PROP_IMAGE_SVG = "svg"; private static final int BASE64_OPTIONS = Base64.NO_WRAP | Base64.NO_PADDING; public enum ImageType { NONE, BITMAP, SVG } private ImageType imageType = ImageType.NONE; private Uri externalUri = null; private Bitmap bitmap = null; private SVG svg = null; private String svgData = null; private final RectF rect = new RectF(); private int originalWidth = 0, originalHeight = 0; private ColorFilter colorFilter = null; /********************************************************* * Creating *********************************************************/ public CustomImageView(Context context) { super(context); } public CustomImageView(Context context, AttributeSet attrs) { super(context, attrs); } public CustomImageView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); } public void prepare(AppCompatActivity activity, FormulaChangeIf termChangeIf) { super.prepare(SymbolType.TEXT, activity, termChangeIf); setText(getContext().getResources().getString(R.string.image_fragment_text)); clear(); } @Override public void updateTextSize(ScaledDimensions dimen, int termDepth) { super.updateTextSize(dimen, termDepth); setPadding(strokeWidth, strokeWidth, strokeWidth, strokeWidth); } public ImageType getImageType() { return imageType; } public int getOriginalWidth() { return originalWidth + 2 * strokeWidth; } public int getOriginalHeight() { return originalHeight + 2 * strokeWidth; } public void setColorType(ImageProperties.ColorType colorType) { if (colorType == ImageProperties.ColorType.AUTO) { colorFilter = new PorterDuffColorFilter( CompatUtils.getThemeColorAttr(getContext(), R.attr.colorFormulaNormal), PorterDuff.Mode.SRC_IN); } else { colorFilter = null; } } /********************************************************* * Read/write interface *********************************************************/ public void loadImage(ImageProperties parameters) { clear(); final String fileName = parameters.fileName; if (fileName == null || fileName.length() == 0) { // not an error: just erase image end exit // Note: parentDocument can be empty (for example, welcome asset at the first start) return; } Uri imageUri; if (parameters.isAsset()) { imageUri = Uri.parse(fileName); } else { imageUri = FileUtils.catUri(getContext(), parameters.parentDirectory, fileName); } if (imageUri == null) { final String error = String.format(getContext().getResources().getString(R.string.error_file_read), fileName); Toast.makeText(activity, error, Toast.LENGTH_LONG).show(); return; } final String fileExt = FileUtils.getFileExt(FileUtils.getFileName(getContext(), imageUri)).toLowerCase( Locale.getDefault()); if (fileExt.equals(".svg")) { // first, try to load image as SVG if (!loadSVG(imageUri, parameters.embedded)) { return; } } if (imageType == ImageType.NONE) { // second, try to load image as a bitmap if (!loadBitmap(imageUri, parameters.embedded)) { return; } } if (imageType == ImageType.NONE) { // finally, try to load image as SVG loadSVG(imageUri, parameters.embedded); } // error if nothing loaded if (imageType == ImageType.NONE) { final String error = String.format(getContext().getResources().getString(R.string.error_file_read), fileName); Toast.makeText(activity, error, Toast.LENGTH_LONG).show(); } } /** * Parcelable interface: procedure writes the formula state */ @Override @SuppressLint("MissingSuperCall") public Parcelable onSaveInstanceState() { Bundle bundle = new Bundle(); bundle.putString(STATE_IMAGE_TYPE, imageType.toString()); if (externalUri != null) { bundle.putString(STATE_IMAGE_URI, externalUri.toString()); } else if (imageType == ImageType.BITMAP && bitmap != null) { bundle.putString(STATE_IMAGE_BITMAP, getEncodedImage(bitmap)); } else if (imageType == ImageType.SVG && svgData != null) { bundle.putString(STATE_IMAGE_SVG, svgData); } return bundle; } /** * Parcelable interface: procedure reads the formula state */ @Override @SuppressLint("MissingSuperCall") public void onRestoreInstanceState(Parcelable state) { if (state == null) { return; } if (state instanceof Bundle) { Bundle bundle = (Bundle) state; final ImageType type = ImageType.valueOf(bundle.getString(STATE_IMAGE_TYPE)); Uri uri = null; final String uriStr = bundle.getString(STATE_IMAGE_URI); if (uriStr != null) { uri = Uri.parse(uriStr); } switch (type) { case NONE: // nothing to do break; case BITMAP: if (uri != null) { loadBitmap(uri, /*isEmbedded=*/ false); } else { setBitmap(getDecodedImage(bundle.getString(STATE_IMAGE_BITMAP))); } break; case SVG: if (uri != null) { loadSVG(uri, /*isEmbedded=*/ false); } else { setSvg(bundle.getString(STATE_IMAGE_SVG)); } break; } } } private String getEncodedImage(Bitmap b) { try { final ByteArrayOutputStream stream = new ByteArrayOutputStream(); b.compress(Bitmap.CompressFormat.PNG, 100, stream); final String encodedImage = Base64.encodeToString(stream.toByteArray(), BASE64_OPTIONS); stream.close(); return encodedImage; } catch (Exception e) { ViewUtils.Debug(this, e.getLocalizedMessage()); return ""; } } private Bitmap getDecodedImage(String s) { try { final byte[] imageDecoded = Base64.decode(s, BASE64_OPTIONS); if (imageDecoded == null) { throw new Exception("cannot decode image, string lenght = " + s.length()); } ByteArrayInputStream imageStream = new ByteArrayInputStream(imageDecoded); final Bitmap b = BitmapFactory.decodeStream(imageStream); imageStream.close(); return b; } catch (Exception e) { ViewUtils.Debug(this, e.getLocalizedMessage()); return null; } } public void writeToXml(XmlSerializer serializer) throws Exception { switch (imageType) { case NONE: serializer.cdsect(""); break; case BITMAP: if (bitmap != null) { try { ByteArrayOutputStream stream = new ByteArrayOutputStream(); bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream); final String encodedImage = Base64.encodeToString(stream.toByteArray(), BASE64_OPTIONS); serializer.attribute(FormulaList.XML_NS, XML_PROP_BIN_ENCODING, "base64"); serializer.attribute(FormulaList.XML_NS, XML_PROP_IMAGE_TYPE, XML_PROP_IMAGE_PNG); serializer.cdsect(encodedImage); stream.close(); } catch (OutOfMemoryError | Exception ex) { String error = getContext().getResources().getString(R.string.error_out_of_memory); Toast.makeText(getContext(), error, Toast.LENGTH_LONG).show(); return; } } else { serializer.cdsect(""); } break; case SVG: if (svgData != null) { final String encodedImage = Base64.encodeToString(svgData.getBytes(), BASE64_OPTIONS); serializer.attribute(FormulaList.XML_NS, XML_PROP_BIN_ENCODING, "base64"); serializer.attribute(FormulaList.XML_NS, XML_PROP_IMAGE_TYPE, XML_PROP_IMAGE_SVG); serializer.cdsect(encodedImage); } else { serializer.cdsect(""); } break; } } public void readFromXml(XmlPullParser parser) { clear(); try { // On android version < 11, the standard XmlPullParser implementation has a bug: // it throws a UnsupportedOperationException at getting CDSECT using nextToken(). // Therefore, XmlPullParserFactory.newInstance().newPullParser() shall be used // to create parser instance in order to get it worked on Android versions 8-10. final String type = parser.getAttributeValue(null, XML_PROP_IMAGE_TYPE); if (type == null) { throw new Exception("image type is unknown"); } final int event = parser.nextToken(); if (event == XmlPullParser.CDSECT) { final String imageText = parser.getText(); if (imageText == null || imageText.length() == 0) { throw new Exception("empty CDSECT"); } byte[] imageDecoded = Base64.decode(imageText, BASE64_OPTIONS); if (imageDecoded == null) { throw new Exception("cannot decode image, string lenght = " + imageText.length()); } if (XML_PROP_IMAGE_PNG.equalsIgnoreCase(type)) { ByteArrayInputStream imageStream = new ByteArrayInputStream(imageDecoded); setBitmap(BitmapFactory.decodeStream(imageStream)); imageStream.close(); } else if (XML_PROP_IMAGE_SVG.equalsIgnoreCase(type)) { setSvg(new String(imageDecoded)); } } else { throw new Exception("CDSECT is not found"); } } catch (Exception e) { ViewUtils.Debug(this, e.getLocalizedMessage()); } } /********************************************************* * Painting *********************************************************/ @SuppressLint("DrawAllocation") @Override protected void onDraw(Canvas c) { try { rect.set(getPaddingLeft(), getPaddingTop(), this.getRight() - this.getLeft() - getPaddingRight() - 1, this.getBottom() - this.getTop() - getPaddingBottom() - 1); paint.setColor(getCurrentTextColor()); paint.setStrokeWidth(strokeWidth); if (imageType == ImageType.SVG && svg != null) { final int width = (int) rect.width(); final int height = (int) rect.height(); bitmap = ViewUtils.pictureToBitmap(svg.renderToPicture(width, height), width, height); paint.setColorFilter(colorFilter); c.drawBitmap(bitmap, null, rect, paint); } else if (imageType == ImageType.BITMAP && bitmap != null) { paint.setColorFilter(colorFilter); c.drawBitmap(bitmap, null, rect, paint); } else { // do not set color filter for text image super.onDraw(c); } } catch (OutOfMemoryError | Exception ex) { String error = getContext().getResources().getString(R.string.error_out_of_memory); Toast.makeText(getContext(), error, Toast.LENGTH_LONG).show(); } } /********************************************************* * Special methods *********************************************************/ private void clear() { imageType = ImageType.NONE; externalUri = null; bitmap = null; svg = null; svgData = null; // Auto-setup of image size depending on display size final DisplayMetrics displayMetrics = getContext().getResources().getDisplayMetrics(); originalHeight = Math.min(displayMetrics.heightPixels, displayMetrics.widthPixels) - 2 * getContext().getResources().getDimensionPixelOffset(R.dimen.activity_horizontal_margin); originalWidth = originalHeight; } private void setBitmap(Bitmap bitmap) { this.bitmap = bitmap; imageType = this.bitmap == null ? ImageType.NONE : ImageType.BITMAP; if (bitmap != null) { originalWidth = bitmap.getWidth(); originalHeight = bitmap.getHeight(); } } private void setSvg(String svgData) { svg = null; try { svg = SVG.getFromString(svgData); originalWidth = (int) svg.getDocumentWidth(); originalHeight = (int) svg.getDocumentHeight(); svg.setDocumentWidth("100%"); svg.setDocumentHeight("100%"); svg.setDocumentViewBox(0, 0, originalWidth, originalHeight); } catch (SVGParseException e) { // nothing to do } if (svg != null) { this.svgData = svgData; } imageType = this.svg == null ? ImageType.NONE : ImageType.SVG; } private boolean loadBitmap(Uri imageUri, boolean isEmbedded) { InputStream stream = FileUtils.getInputStream(getContext(), imageUri); if (stream != null) { try { setBitmap(BitmapFactory.decodeStream(stream)); if (!isEmbedded) { externalUri = imageUri; } } catch (OutOfMemoryError ex) { String error = getContext().getResources().getString(R.string.error_out_of_memory); Toast.makeText(getContext(), error, Toast.LENGTH_LONG).show(); // no return since we need to close stream } catch (Exception e) { // nothing to do } FileUtils.closeStream(stream); return true; } return false; } private String getStringFromInputStream(InputStream stream) throws IOException { final BufferedReader r = new BufferedReader(new InputStreamReader(stream)); final StringBuilder total = new StringBuilder(stream.available()); String line; while ((line = r.readLine()) != null) { total.append(line); } return total.toString(); } private boolean loadSVG(Uri imageUri, boolean isEmbedded) { InputStream stream = FileUtils.getInputStream(getContext(), imageUri); if (stream != null) { try { setSvg(getStringFromInputStream(stream)); if (!isEmbedded) { externalUri = imageUri; } } catch (Exception e) { // nothing to do } FileUtils.closeStream(stream); return true; } return false; } }