diff --git a/build.gradle b/build.gradle index a70b54a833..edf07c097a 100644 --- a/build.gradle +++ b/build.gradle @@ -112,16 +112,6 @@ allprojects { groups.google.group.each { includeGroup it } } } - //noinspection JcenterRepositoryObsolete - // Do not use `jcenter`, it prevents Dependabot from working properly - maven { - url 'https://jcenter.bintray.com' - content { - groups.jcenter.regex.each { includeGroupByRegex it } - groups.jcenter.group.each { includeGroup it } - } - } - maven { url 'https://s01.oss.sonatype.org/content/repositories/snapshots' content { @@ -129,7 +119,6 @@ allprojects { groups.mavenSnapshots.group.each { includeGroup it } } } - } tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { diff --git a/changelog.d/8556.misc b/changelog.d/8556.misc new file mode 100644 index 0000000000..42ff3f60fe --- /dev/null +++ b/changelog.d/8556.misc @@ -0,0 +1 @@ +Include some source code in our project to remove our dependency to artifact hosted by bintray (Jcenter). diff --git a/dependencies_groups.gradle b/dependencies_groups.gradle index 66d07f258b..c023eab2eb 100644 --- a/dependencies_groups.gradle +++ b/dependencies_groups.gradle @@ -115,6 +115,7 @@ ext.groups = [ 'com.linkedin.dexmaker', 'com.mapbox.mapboxsdk', 'com.nulab-inc', + 'com.otaliastudios', 'com.otaliastudios.opengl', 'com.parse.bolts', 'com.pinterest', @@ -234,18 +235,4 @@ ext.groups = [ 'xml-apis', ] ], - jcenter : [ - regex: [ - ], - group: [ - 'com.amulyakhare', - 'com.otaliastudios', - 'com.yqritc', - // https://github.com/cmelchior/realmfieldnameshelper/issues/42 - 'dk.ilios', - 'im.dlg', - 'me.dm7.barcodescanner', - 'me.gujun.android', - ] - ] ] diff --git a/library/external/autocomplete/build.gradle b/library/external/autocomplete/build.gradle new file mode 100644 index 0000000000..39d4d4b19e --- /dev/null +++ b/library/external/autocomplete/build.gradle @@ -0,0 +1,32 @@ +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + namespace "com.otaliastudios.autocomplete" + + compileSdk versions.compileSdk + + defaultConfig { + minSdk versions.minSdk + targetSdk versions.targetSdk + } + + compileOptions { + sourceCompatibility versions.sourceCompat + targetCompatibility versions.targetCompat + } + + kotlinOptions { + jvmTarget = "11" + } +} + +dependencies { + implementation libs.androidx.recyclerview +} + +afterEvaluate { + tasks.findAll { it.name.startsWith("lint") }.each { + it.enabled = false + } +} diff --git a/library/external/autocomplete/src/main/java/com/otaliastudios/autocomplete/Autocomplete.java b/library/external/autocomplete/src/main/java/com/otaliastudios/autocomplete/Autocomplete.java new file mode 100644 index 0000000000..cf58146f14 --- /dev/null +++ b/library/external/autocomplete/src/main/java/com/otaliastudios/autocomplete/Autocomplete.java @@ -0,0 +1,434 @@ +package com.otaliastudios.autocomplete; + +import android.database.DataSetObserver; +import android.graphics.drawable.Drawable; +import android.os.Handler; +import android.os.Looper; +import android.text.Editable; +import android.text.Selection; +import android.text.SpanWatcher; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.TextWatcher; +import android.util.Log; +import android.util.TypedValue; +import android.view.Gravity; +import android.view.Window; +import android.view.WindowManager; +import android.widget.EditText; +import android.widget.PopupWindow; + +import androidx.annotation.NonNull; + + +/** + * Entry point for adding Autocomplete behavior to a {@link EditText}. + * + * You can construct a {@code Autocomplete} using the builder provided by {@link Autocomplete#on(EditText)}. + * Building is enough, but you can hold a reference to this class to call its public methods. + * + * Requires: + * - {@link EditText}: this is both the anchor for the popup, and the source of text events that we listen to + * - {@link AutocompletePresenter}: this presents items in the popup window. See class for more info. + * - {@link AutocompleteCallback}: if specified, this listens to click events and visibility changes + * - {@link AutocompletePolicy}: if specified, this controls how and when to show the popup based on text events + * If not, this defaults to {@link SimplePolicy}: shows the popup when text.length() bigger than 0. + */ +public final class Autocomplete implements TextWatcher, SpanWatcher { + + private final static String TAG = Autocomplete.class.getSimpleName(); + private final static boolean DEBUG = false; + + private static void log(String log) { + if (DEBUG) Log.e(TAG, log); + } + + /** + * Builder for building {@link Autocomplete}. + * The only mandatory item is a presenter, {@link #with(AutocompletePresenter)}. + * + * @param the data model + */ + public final static class Builder { + private EditText source; + private AutocompletePresenter presenter; + private AutocompletePolicy policy; + private AutocompleteCallback callback; + private Drawable backgroundDrawable; + private float elevationDp = 6; + + private Builder(EditText source) { + this.source = source; + } + + /** + * Registers the {@link AutocompletePresenter} to be used, responsible for showing + * items. See the class for info. + * + * @param presenter desired presenter + * @return this for chaining + */ + public Builder with(AutocompletePresenter presenter) { + this.presenter = presenter; + return this; + } + + /** + * Registers the {@link AutocompleteCallback} to be used, responsible for listening to + * clicks provided by the presenter, and visibility changes. + * + * @param callback desired callback + * @return this for chaining + */ + public Builder with(AutocompleteCallback callback) { + this.callback = callback; + return this; + } + + /** + * Registers the {@link AutocompletePolicy} to be used, responsible for showing / dismissing + * the popup when certain events happen (e.g. certain characters are typed). + * + * @param policy desired policy + * @return this for chaining + */ + public Builder with(AutocompletePolicy policy) { + this.policy = policy; + return this; + } + + /** + * Sets a background drawable for the popup. + * + * @param backgroundDrawable drawable + * @return this for chaining + */ + public Builder with(Drawable backgroundDrawable) { + this.backgroundDrawable = backgroundDrawable; + return this; + } + + /** + * Sets elevation for the popup. Defaults to 6 dp. + * + * @param elevationDp popup elevation, in DP + * @return this for chaning. + */ + public Builder with(float elevationDp) { + this.elevationDp = elevationDp; + return this; + } + + /** + * Builds an Autocomplete instance. This is enough for autocomplete to be set up, + * but you can hold a reference to the object and call its public methods. + * + * @return an Autocomplete instance, if you need it + * + * @throws RuntimeException if either EditText or the presenter are null + */ + public Autocomplete build() { + if (source == null) throw new RuntimeException("Autocomplete needs a source!"); + if (presenter == null) throw new RuntimeException("Autocomplete needs a presenter!"); + if (policy == null) policy = new SimplePolicy(); + return new Autocomplete(this); + } + + private void clear() { + source = null; + presenter = null; + callback = null; + policy = null; + backgroundDrawable = null; + elevationDp = 6; + } + } + + /** + * Entry point for building autocomplete on a certain {@link EditText}. + * @param anchor the anchor for the popup, and the source of text events + * @param your data model + * @return a Builder for set up + */ + public static Builder on(EditText anchor) { + return new Builder(anchor); + } + + private AutocompletePolicy policy; + private AutocompletePopup popup; + private AutocompletePresenter presenter; + private AutocompleteCallback callback; + private EditText source; + + private boolean block; + private boolean disabled; + private boolean openBefore; + private String lastQuery = "null"; + + private Autocomplete(Builder builder) { + policy = builder.policy; + presenter = builder.presenter; + callback = builder.callback; + source = builder.source; + + // Set up popup + popup = new AutocompletePopup(source.getContext()); + popup.setAnchorView(source); + popup.setGravity(Gravity.START); + popup.setModal(false); + popup.setBackgroundDrawable(builder.backgroundDrawable); + popup.setElevation(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, builder.elevationDp, + source.getContext().getResources().getDisplayMetrics())); + + // popup dimensions + AutocompletePresenter.PopupDimensions dim = this.presenter.getPopupDimensions(); + popup.setWidth(dim.width); + popup.setHeight(dim.height); + popup.setMaxWidth(dim.maxWidth); + popup.setMaxHeight(dim.maxHeight); + + // Fire visibility events + popup.setOnDismissListener(new PopupWindow.OnDismissListener() { + @Override + public void onDismiss() { + lastQuery = "null"; + if (callback != null) callback.onPopupVisibilityChanged(false); + boolean saved = block; + block = true; + policy.onDismiss(source.getText()); + block = saved; + presenter.hideView(); + } + }); + + // Set up source + source.getText().setSpan(this, 0, source.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE); + source.addTextChangedListener(this); + + // Set up presenter + presenter.registerClickProvider(new AutocompletePresenter.ClickProvider() { + @Override + public void click(@NonNull T item) { + AutocompleteCallback callback = Autocomplete.this.callback; + EditText edit = Autocomplete.this.source; + if (callback == null) return; + boolean saved = block; + block = true; + boolean dismiss = callback.onPopupItemClicked(edit.getText(), item); + if (dismiss) dismissPopup(); + block = saved; + } + }); + + builder.clear(); + } + + /** + * Controls how the popup operates with an input method. + * + * If the popup is showing, calling this method will take effect only + * the next time the popup is shown. + * + * @param mode a {@link PopupWindow} input method mode + */ + public void setInputMethodMode(int mode) { + popup.setInputMethodMode(mode); + } + + /** + * Sets the operating mode for the soft input area. + * + * @param mode The desired mode, see {@link WindowManager.LayoutParams#softInputMode} + */ + public void setSoftInputMode(int mode) { + popup.setSoftInputMode(mode); + } + + /** + * Shows the popup with the given query. + * There is rarely need to call this externally: it is already triggered by events on the anchor. + * To control when this is called, provide a good implementation of {@link AutocompletePolicy}. + * + * @param query query text. + */ + public void showPopup(@NonNull CharSequence query) { + if (isPopupShowing() && lastQuery.equals(query.toString())) return; + lastQuery = query.toString(); + + log("showPopup: called with filter "+query); + if (!isPopupShowing()) { + log("showPopup: showing"); + presenter.registerDataSetObserver(new Observer()); // Calling new to avoid leaking... maybe... + popup.setView(presenter.getView()); + presenter.showView(); + popup.show(); + if (callback != null) callback.onPopupVisibilityChanged(true); + } + log("showPopup: popup should be showing... "+isPopupShowing()); + presenter.onQuery(query); + } + + /** + * Dismisses the popup, if showing. + * There is rarely need to call this externally: it is already triggered by events on the anchor. + * To control when this is called, provide a good implementation of {@link AutocompletePolicy}. + */ + public void dismissPopup() { + if (isPopupShowing()) { + popup.dismiss(); + } + } + + /** + * Returns true if the popup is showing. + * @return whether the popup is currently showing + */ + public boolean isPopupShowing() { + return this.popup.isShowing(); + } + + /** + * Switch to control the autocomplete behavior. When disabled, no popup is shown. + * This is useful if you want to do runtime edits to the anchor text, without triggering + * the popup. + * + * @param enabled whether to enable autocompletion + */ + public void setEnabled(boolean enabled) { + disabled = !enabled; + } + + /** + * Sets the gravity for the popup. Basically only {@link Gravity#START} and {@link Gravity#END} + * do work. + * + * @param gravity gravity for the popup + */ + public void setGravity(int gravity) { + popup.setGravity(gravity); + } + + /** + * Controls the vertical offset of the popup from the EditText anchor. + * + * @param offset offset in pixels. + */ + public void setOffsetFromAnchor(int offset) { popup.setVerticalOffset(offset); } + + /** + * Controls whether the popup should listen to clicks outside its boundaries. + * + * @param outsideTouchable true to listen to outside clicks + */ + public void setOutsideTouchable(boolean outsideTouchable) { popup.setOutsideTouchable(outsideTouchable); } + + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + if (block || disabled) return; + openBefore = isPopupShowing(); + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + if (block || disabled) return; + if (openBefore && !isPopupShowing()) { + return; // Copied from somewhere. + } + if (!(s instanceof Spannable)) { + source.setText(new SpannableString(s)); + return; + } + Spannable sp = (Spannable) s; + + int cursor = source.getSelectionEnd(); + log("onTextChanged: cursor end position is "+cursor); + if (cursor == -1) { // No cursor present. + dismissPopup(); return; + } + if (cursor != source.getSelectionStart()) { + // Not sure about this. We should have no problems dealing with multi selections, + // we just take the end... + // dismissPopup(); return; + } + + boolean b = block; + block = true; // policy might add spans or other stuff. + if (isPopupShowing() && policy.shouldDismissPopup(sp, cursor)) { + log("onTextChanged: dismissing"); + dismissPopup(); + } else if (isPopupShowing() || policy.shouldShowPopup(sp, cursor)) { + // LOG.now("onTextChanged: updating with filter "+policy.getQuery(sp)); + showPopup(policy.getQuery(sp)); + } + block = b; + } + + @Override + public void afterTextChanged(Editable s) {} + + @Override + public void onSpanAdded(Spannable text, Object what, int start, int end) {} + + @Override + public void onSpanRemoved(Spannable text, Object what, int start, int end) {} + + @Override + public void onSpanChanged(Spannable text, Object what, int ostart, int oend, int nstart, int nend) { + if (disabled || block) return; + if (what == Selection.SELECTION_END) { + // Selection end changed from ostart to nstart. Trigger a check. + log("onSpanChanged: selection end moved from "+ostart+" to "+nstart); + log("onSpanChanged: block is "+block); + boolean b = block; + block = true; + if (!isPopupShowing() && policy.shouldShowPopup(text, nstart)) { + showPopup(policy.getQuery(text)); + } + block = b; + } + } + + private class Observer extends DataSetObserver implements Runnable { + private Handler ui = new Handler(Looper.getMainLooper()); + + @Override + public void onChanged() { + // ??? Not sure this is needed... + ui.post(this); + } + + @Override + public void run() { + if (isPopupShowing()) { + // Call show again to revisit width and height. + popup.show(); + } + } + } + + /** + * A very simple {@link AutocompletePolicy} implementation. + * Popup is shown when text length is bigger than 0, and hidden when text is empty. + * The query string is the whole text. + */ + public static class SimplePolicy implements AutocompletePolicy { + @Override + public boolean shouldShowPopup(@NonNull Spannable text, int cursorPos) { + return text.length() > 0; + } + + @Override + public boolean shouldDismissPopup(@NonNull Spannable text, int cursorPos) { + return text.length() == 0; + } + + @NonNull + @Override + public CharSequence getQuery(@NonNull Spannable text) { + return text; + } + + @Override + public void onDismiss(@NonNull Spannable text) {} + } +} diff --git a/library/external/autocomplete/src/main/java/com/otaliastudios/autocomplete/AutocompleteCallback.java b/library/external/autocomplete/src/main/java/com/otaliastudios/autocomplete/AutocompleteCallback.java new file mode 100644 index 0000000000..56870db610 --- /dev/null +++ b/library/external/autocomplete/src/main/java/com/otaliastudios/autocomplete/AutocompleteCallback.java @@ -0,0 +1,29 @@ +package com.otaliastudios.autocomplete; + +import android.text.Editable; + +import androidx.annotation.NonNull; + +/** + * Optional callback to be passed to {@link Autocomplete.Builder}. + */ +public interface AutocompleteCallback { + + /** + * Called when an item inside your list is clicked. + * This works if your presenter has dispatched a click event. + * At this point you can edit the text, e.g. {@code editable.append(item.toString())}. + * + * @param editable editable text that you can work on + * @param item item that was clicked + * @return true if the action is valid and the popup can be dismissed + */ + boolean onPopupItemClicked(@NonNull Editable editable, @NonNull T item); + + /** + * Called when popup visibility state changes. + * + * @param shown true if the popup was just shown, false if it was just hidden + */ + void onPopupVisibilityChanged(boolean shown); +} diff --git a/library/external/autocomplete/src/main/java/com/otaliastudios/autocomplete/AutocompletePolicy.java b/library/external/autocomplete/src/main/java/com/otaliastudios/autocomplete/AutocompletePolicy.java new file mode 100644 index 0000000000..05dfaa9f0a --- /dev/null +++ b/library/external/autocomplete/src/main/java/com/otaliastudios/autocomplete/AutocompletePolicy.java @@ -0,0 +1,64 @@ +package com.otaliastudios.autocomplete; + +import android.text.Spannable; + +import androidx.annotation.NonNull; + +/** + * This interface controls when to show or hide the popup window, and, in the first case, + * what text should be passed to the popup {@link AutocompletePresenter}. + * + * @see Autocomplete.SimplePolicy for the simplest possible implementation + */ +public interface AutocompletePolicy { + + /** + * Called to understand whether the popup should be shown. Some naive examples: + * - Show when there's text: {@code return text.length() > 0} + * - Show when last char is @: {@code return text.getCharAt(text.length()-1) == '@'} + * + * @param text current text, along with its Spans + * @param cursorPos the position of the cursor + * @return true if popup should be shown + */ + boolean shouldShowPopup(@NonNull Spannable text, int cursorPos); + + /** + * Called to understand whether a currently shown popup should be closed, maybe + * because text is invalid. A reasonable implementation is + * {@code return !shouldShowPopup(text, cursorPos)}. + * + * However this is defined so you can add or clear spans. + * + * @param text current text, along with its Spans + * @param cursorPos the position of the cursor + * @return true if popup should be hidden + */ + boolean shouldDismissPopup(@NonNull Spannable text, int cursorPos); + + /** + * Called to understand which query should be passed to {@link AutocompletePresenter} + * for a showing popup. If this is called, {@link #shouldShowPopup(Spannable, int)} just returned + * true, or {@link #shouldDismissPopup(Spannable, int)} just returned false. + * + * This is useful to understand which part of the text should be passed to presenters. + * For example, user might have typed '@john' to select a username, but you just want to + * search for 'john'. + * + * For more complex cases, you can add inclusive Spans in {@link #shouldShowPopup(Spannable, int)}, + * and get the span position here. + * + * @param text current text, along with its Spans + * @return the query for presenter + */ + @NonNull + CharSequence getQuery(@NonNull Spannable text); + + /** + * Called when popup is dismissed. This can be used, for instance, to clear custom Spans + * from the text. + * + * @param text text at the moment of dismissing + */ + void onDismiss(@NonNull Spannable text); +} diff --git a/library/external/autocomplete/src/main/java/com/otaliastudios/autocomplete/AutocompletePopup.java b/library/external/autocomplete/src/main/java/com/otaliastudios/autocomplete/AutocompletePopup.java new file mode 100644 index 0000000000..6467085068 --- /dev/null +++ b/library/external/autocomplete/src/main/java/com/otaliastudios/autocomplete/AutocompletePopup.java @@ -0,0 +1,521 @@ +package com.otaliastudios.autocomplete; + +import android.content.Context; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.os.Build; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowManager; +import android.widget.PopupWindow; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StyleRes; +import androidx.core.view.ViewCompat; +import androidx.core.widget.PopupWindowCompat; + +/** + * A simplified version of andriod.widget.ListPopupWindow, which is the class used by + * AutocompleteTextView. + * + * Other than being simplified, this deals with Views rather than ListViews, so the content + * can be whatever. Lots of logic (clicks, selections etc.) has been removed because we manage that + * in {@link AutocompletePresenter}. + * + */ +class AutocompletePopup { + private Context mContext; + private ViewGroup mView; + private int mHeight = ViewGroup.LayoutParams.WRAP_CONTENT; + private int mWidth = ViewGroup.LayoutParams.WRAP_CONTENT; + private int mMaxHeight = Integer.MAX_VALUE; + private int mMaxWidth = Integer.MAX_VALUE; + private int mUserMaxHeight = Integer.MAX_VALUE; + private int mUserMaxWidth = Integer.MAX_VALUE; + private int mHorizontalOffset = 0; + private int mVerticalOffset = 0; + private boolean mVerticalOffsetSet; + private int mGravity = Gravity.NO_GRAVITY; + private boolean mAlwaysVisible = false; + private boolean mOutsideTouchable = true; + private View mAnchorView; + private final Rect mTempRect = new Rect(); + private boolean mModal; + private PopupWindow mPopup; + + + /** + * Create a new, empty popup window capable of displaying items from a ListAdapter. + * Backgrounds should be set using {@link #setBackgroundDrawable(Drawable)}. + * + * @param context Context used for contained views. + */ + AutocompletePopup(@NonNull Context context) { + super(); + mContext = context; + mPopup = new PopupWindow(context); + mPopup.setInputMethodMode(PopupWindow.INPUT_METHOD_NEEDED); + } + + + /** + * Set whether this window should be modal when shown. + * + *

If a popup window is modal, it will receive all touch and key input. + * If the user touches outside the popup window's content area the popup window + * will be dismissed. + * @param modal {@code true} if the popup window should be modal, {@code false} otherwise. + */ + @SuppressWarnings("SameParameterValue") + void setModal(boolean modal) { + mModal = modal; + mPopup.setFocusable(modal); + } + + /** + * Returns whether the popup window will be modal when shown. + * @return {@code true} if the popup window will be modal, {@code false} otherwise. + */ + @SuppressWarnings("unused") + boolean isModal() { + return mModal; + } + + void setElevation(float elevationPx) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) mPopup.setElevation(elevationPx); + } + + /** + * Sets whether the drop-down should remain visible under certain conditions. + * + * The drop-down will occupy the entire screen below {@link #getAnchorView} regardless + * of the size or content of the list. {@link #getBackground()} will fill any space + * that is not used by the list. + * @param dropDownAlwaysVisible Whether to keep the drop-down visible. + * + */ + @SuppressWarnings("unused") + void setDropDownAlwaysVisible(boolean dropDownAlwaysVisible) { + mAlwaysVisible = dropDownAlwaysVisible; + } + + /** + * @return Whether the drop-down is visible under special conditions. + */ + @SuppressWarnings("unused") + boolean isDropDownAlwaysVisible() { + return mAlwaysVisible; + } + + void setOutsideTouchable(boolean outsideTouchable) { + mOutsideTouchable = outsideTouchable; + } + + @SuppressWarnings("WeakerAccess") + boolean isOutsideTouchable() { + return mOutsideTouchable && !mAlwaysVisible; + } + + /** + * Sets the operating mode for the soft input area. + * @param mode The desired mode, see + * {@link android.view.WindowManager.LayoutParams#softInputMode} + * for the full list + * @see android.view.WindowManager.LayoutParams#softInputMode + * @see #getSoftInputMode() + */ + void setSoftInputMode(int mode) { + mPopup.setSoftInputMode(mode); + } + + /** + * Returns the current value in {@link #setSoftInputMode(int)}. + * @see #setSoftInputMode(int) + * @see android.view.WindowManager.LayoutParams#softInputMode + */ + @SuppressWarnings({"WeakerAccess", "unused"}) + int getSoftInputMode() { + return mPopup.getSoftInputMode(); + } + + /** + * @return The background drawable for the popup window. + */ + @SuppressWarnings({"WeakerAccess", "unused"}) + @Nullable + Drawable getBackground() { + return mPopup.getBackground(); + } + + /** + * Sets a drawable to be the background for the popup window. + * @param d A drawable to set as the background. + */ + void setBackgroundDrawable(@Nullable Drawable d) { + mPopup.setBackgroundDrawable(d); + } + + /** + * Set an animation style to use when the popup window is shown or dismissed. + * @param animationStyle Animation style to use. + */ + @SuppressWarnings("unused") + void setAnimationStyle(@StyleRes int animationStyle) { + mPopup.setAnimationStyle(animationStyle); + } + + /** + * Returns the animation style that will be used when the popup window is + * shown or dismissed. + * @return Animation style that will be used. + */ + @SuppressWarnings("unused") + @StyleRes + int getAnimationStyle() { + return mPopup.getAnimationStyle(); + } + + /** + * Returns the view that will be used to anchor this popup. + * @return The popup's anchor view + */ + @SuppressWarnings("WeakerAccess") + View getAnchorView() { + return mAnchorView; + } + + /** + * Sets the popup's anchor view. This popup will always be positioned relative to + * the anchor view when shown. + * @param anchor The view to use as an anchor. + */ + void setAnchorView(@NonNull View anchor) { + mAnchorView = anchor; + } + + /** + * Set the horizontal offset of this popup from its anchor view in pixels. + * @param offset The horizontal offset of the popup from its anchor. + */ + @SuppressWarnings("unused") + void setHorizontalOffset(int offset) { + mHorizontalOffset = offset; + } + + /** + * Set the vertical offset of this popup from its anchor view in pixels. + * @param offset The vertical offset of the popup from its anchor. + */ + void setVerticalOffset(int offset) { + mVerticalOffset = offset; + mVerticalOffsetSet = true; + } + + /** + * Set the gravity of the dropdown list. This is commonly used to + * set gravity to START or END for alignment with the anchor. + * @param gravity Gravity value to use + */ + void setGravity(int gravity) { + mGravity = gravity; + } + + /** + * @return The width of the popup window in pixels. + */ + @SuppressWarnings("unused") + int getWidth() { + return mWidth; + } + + /** + * Sets the width of the popup window in pixels. Can also be MATCH_PARENT + * or WRAP_CONTENT. + * @param width Width of the popup window. + */ + void setWidth(int width) { + mWidth = width; + } + + /** + * Sets the width of the popup window by the size of its content. The final width may be + * larger to accommodate styled window dressing. + * @param width Desired width of content in pixels. + */ + @SuppressWarnings("unused") + void setContentWidth(int width) { + Drawable popupBackground = mPopup.getBackground(); + if (popupBackground != null) { + popupBackground.getPadding(mTempRect); + width += mTempRect.left + mTempRect.right; + } + setWidth(width); + } + + void setMaxWidth(int width) { + if (width > 0) { + mUserMaxWidth = width; + } + } + + /** + * @return The height of the popup window in pixels. + */ + @SuppressWarnings("unused") + int getHeight() { + return mHeight; + } + + /** + * Sets the height of the popup window in pixels. Can also be MATCH_PARENT. + * @param height Height of the popup window. + */ + void setHeight(int height) { + mHeight = height; + } + + /** + * Sets the height of the popup window by the size of its content. The final height may be + * larger to accommodate styled window dressing. + * @param height Desired height of content in pixels. + */ + @SuppressWarnings("unused") + void setContentHeight(int height) { + Drawable popupBackground = mPopup.getBackground(); + if (popupBackground != null) { + popupBackground.getPadding(mTempRect); + height += mTempRect.top + mTempRect.bottom; + } + setHeight(height); + } + + void setMaxHeight(int height) { + if (height > 0) { + mUserMaxHeight = height; + } + } + + void setOnDismissListener(PopupWindow.OnDismissListener listener) { + mPopup.setOnDismissListener(listener); + } + + /** + * Show the popup list. If the list is already showing, this method + * will recalculate the popup's size and position. + */ + void show() { + if (!ViewCompat.isAttachedToWindow(getAnchorView())) return; + + int height = buildDropDown(); + final boolean noInputMethod = isInputMethodNotNeeded(); + int mDropDownWindowLayoutType = WindowManager.LayoutParams.TYPE_APPLICATION_SUB_PANEL; + PopupWindowCompat.setWindowLayoutType(mPopup, mDropDownWindowLayoutType); + + if (mPopup.isShowing()) { + // First pass for this special case, don't know why. + if (mHeight == ViewGroup.LayoutParams.MATCH_PARENT) { + int tempWidth = mWidth == ViewGroup.LayoutParams.MATCH_PARENT ? ViewGroup.LayoutParams.MATCH_PARENT : 0; + if (noInputMethod) { + mPopup.setWidth(tempWidth); + mPopup.setHeight(0); + } else { + mPopup.setWidth(tempWidth); + mPopup.setHeight(ViewGroup.LayoutParams.MATCH_PARENT); + } + } + + // The call to PopupWindow's update method below can accept -1 + // for any value you do not want to update. + + // Width. + int widthSpec; + if (mWidth == ViewGroup.LayoutParams.MATCH_PARENT) { + widthSpec = -1; + } else if (mWidth == ViewGroup.LayoutParams.WRAP_CONTENT) { + widthSpec = getAnchorView().getWidth(); + } else { + widthSpec = mWidth; + } + widthSpec = Math.min(widthSpec, mMaxWidth); + widthSpec = (widthSpec < 0) ? - 1 : widthSpec; + + // Height. + int heightSpec; + if (mHeight == ViewGroup.LayoutParams.MATCH_PARENT) { + heightSpec = noInputMethod ? height : ViewGroup.LayoutParams.MATCH_PARENT; + } else if (mHeight == ViewGroup.LayoutParams.WRAP_CONTENT) { + heightSpec = height; + } else { + heightSpec = mHeight; + } + heightSpec = Math.min(heightSpec, mMaxHeight); + heightSpec = (heightSpec < 0) ? - 1 : heightSpec; + + // Update. + mPopup.setOutsideTouchable(isOutsideTouchable()); + if (heightSpec == 0) { + dismiss(); + } else { + mPopup.update(getAnchorView(), mHorizontalOffset, mVerticalOffset, widthSpec, heightSpec); + } + + } else { + int widthSpec; + if (mWidth == ViewGroup.LayoutParams.MATCH_PARENT) { + widthSpec = ViewGroup.LayoutParams.MATCH_PARENT; + } else if (mWidth == ViewGroup.LayoutParams.WRAP_CONTENT) { + widthSpec = getAnchorView().getWidth(); + } else { + widthSpec = mWidth; + } + widthSpec = Math.min(widthSpec, mMaxWidth); + + int heightSpec; + if (mHeight == ViewGroup.LayoutParams.MATCH_PARENT) { + heightSpec = ViewGroup.LayoutParams.MATCH_PARENT; + } else if (mHeight == ViewGroup.LayoutParams.WRAP_CONTENT) { + heightSpec = height; + } else { + heightSpec = mHeight; + } + heightSpec = Math.min(heightSpec, mMaxHeight); + + // Set width and height. + mPopup.setWidth(widthSpec); + mPopup.setHeight(heightSpec); + mPopup.setClippingEnabled(true); + + // use outside touchable to dismiss drop down when touching outside of it, so + // only set this if the dropdown is not always visible + mPopup.setOutsideTouchable(isOutsideTouchable()); + PopupWindowCompat.showAsDropDown(mPopup, getAnchorView(), mHorizontalOffset, mVerticalOffset, mGravity); + } + } + + /** + * Dismiss the popup window. + */ + void dismiss() { + mPopup.dismiss(); + mPopup.setContentView(null); + mView = null; + } + + /** + * Control how the popup operates with an input method: one of + * INPUT_METHOD_FROM_FOCUSABLE, INPUT_METHOD_NEEDED, + * or INPUT_METHOD_NOT_NEEDED. + * + *

If the popup is showing, calling this method will take effect only + * the next time the popup is shown or through a manual call to the {@link #show()} + * method.

+ * + * @see #show() + */ + void setInputMethodMode(int mode) { + mPopup.setInputMethodMode(mode); + } + + + /** + * @return {@code true} if the popup is currently showing, {@code false} otherwise. + */ + boolean isShowing() { + return mPopup.isShowing(); + } + + /** + * @return {@code true} if this popup is configured to assume the user does not need + * to interact with the IME while it is showing, {@code false} otherwise. + */ + @SuppressWarnings("WeakerAccess") + boolean isInputMethodNotNeeded() { + return mPopup.getInputMethodMode() == PopupWindow.INPUT_METHOD_NOT_NEEDED; + } + + + void setView(ViewGroup view) { + mView = view; + mView.setFocusable(true); + mView.setFocusableInTouchMode(true); + ViewGroup dropDownView = mView; + mPopup.setContentView(dropDownView); + ViewGroup.LayoutParams params = mView.getLayoutParams(); + if (params != null) { + if (params.height > 0) setHeight(params.height); + if (params.width > 0) setWidth(params.width); + } + } + + /** + *

Builds the popup window's content and returns the height the popup + * should have. Returns -1 when the content already exists.

+ * + * @return the content's wrap content height or -1 if content already exists + */ + private int buildDropDown() { + int otherHeights = 0; + + // getMaxAvailableHeight() subtracts the padding, so we put it back + // to get the available height for the whole window. + final int paddingVert; + final int paddingHoriz; + final Drawable background = mPopup.getBackground(); + if (background != null) { + background.getPadding(mTempRect); + paddingVert = mTempRect.top + mTempRect.bottom; + paddingHoriz = mTempRect.left + mTempRect.right; + + // If we don't have an explicit vertical offset, determine one from + // the window background so that content will line up. + if (!mVerticalOffsetSet) { + mVerticalOffset = -mTempRect.top; + } + } else { + mTempRect.setEmpty(); + paddingVert = 0; + paddingHoriz = 0; + } + + // Redefine dimensions taking into account maxWidth and maxHeight. + final boolean ignoreBottomDecorations = mPopup.getInputMethodMode() == PopupWindow.INPUT_METHOD_NOT_NEEDED; + final int maxContentHeight = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N ? + mPopup.getMaxAvailableHeight(getAnchorView(), mVerticalOffset, ignoreBottomDecorations) : + mPopup.getMaxAvailableHeight(getAnchorView(), mVerticalOffset); + final int maxContentWidth = mContext.getResources().getDisplayMetrics().widthPixels - paddingHoriz; + + mMaxHeight = Math.min(maxContentHeight + paddingVert, mUserMaxHeight); + mMaxWidth = Math.min(maxContentWidth + paddingHoriz, mUserMaxWidth); + // if (mHeight > 0) mHeight = Math.min(mHeight, maxContentHeight); + // if (mWidth > 0) mWidth = Math.min(mWidth, maxContentWidth); + + if (mAlwaysVisible || mHeight == ViewGroup.LayoutParams.MATCH_PARENT) { + return mMaxHeight; + } + + final int childWidthSpec; + switch (mWidth) { + case ViewGroup.LayoutParams.WRAP_CONTENT: + childWidthSpec = View.MeasureSpec.makeMeasureSpec(maxContentWidth, View.MeasureSpec.AT_MOST); break; + case ViewGroup.LayoutParams.MATCH_PARENT: + childWidthSpec = View.MeasureSpec.makeMeasureSpec(maxContentWidth, View.MeasureSpec.EXACTLY); break; + default: + //noinspection Range + childWidthSpec = View.MeasureSpec.makeMeasureSpec(mWidth, View.MeasureSpec.EXACTLY); break; + } + + // Add padding only if the list has items in it, that way we don't show + // the popup if it is not needed. For this reason, we measure as wrap_content. + mView.measure(childWidthSpec, View.MeasureSpec.makeMeasureSpec(maxContentHeight, View.MeasureSpec.AT_MOST)); + final int viewHeight = mView.getMeasuredHeight(); + if (viewHeight > 0) { + otherHeights += paddingVert + mView.getPaddingTop() + mView.getPaddingBottom(); + } + + return Math.min(viewHeight + otherHeights, mMaxHeight); + } + + +} \ No newline at end of file diff --git a/library/external/autocomplete/src/main/java/com/otaliastudios/autocomplete/AutocompletePresenter.java b/library/external/autocomplete/src/main/java/com/otaliastudios/autocomplete/AutocompletePresenter.java new file mode 100644 index 0000000000..d49e8c8f30 --- /dev/null +++ b/library/external/autocomplete/src/main/java/com/otaliastudios/autocomplete/AutocompletePresenter.java @@ -0,0 +1,129 @@ +package com.otaliastudios.autocomplete; + +import android.content.Context; +import android.database.DataSetObserver; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** + * Base class for presenting items inside a popup. This is abstract and must be implemented. + * + * Most important methods are {@link #getView()} and {@link #onQuery(CharSequence)}. + */ +public abstract class AutocompletePresenter { + + private Context context; + private boolean isShowing; + + @SuppressWarnings("WeakerAccess") + public AutocompletePresenter(@NonNull Context context) { + this.context = context; + } + + /** + * At this point the presenter is passed the {@link ClickProvider}. + * The contract is that {@link ClickProvider#click(Object)} must be called when a list item + * is clicked. This ensure that the autocomplete callback will receive the event. + * + * @param provider a click provider for this presenter. + */ + protected void registerClickProvider(ClickProvider provider) { + + } + + /** + * Useful if you wish to change width/height based on content height. + * The contract is to call {@link DataSetObserver#onChanged()} when your view has + * changes. + * + * This is called after {@link #getView()}. + * + * @param observer the observer. + */ + protected void registerDataSetObserver(@NonNull DataSetObserver observer) {} + + /** + * Called each time the popup is shown. You are meant to inflate the view here. + * You can get a LayoutInflater using {@link #getContext()}. + * + * @return a ViewGroup for the popup + */ + @NonNull + protected abstract ViewGroup getView(); + + /** + * Provide the {@link PopupDimensions} for this popup. Called just once. + * You can use fixed dimensions or {@link android.view.ViewGroup.LayoutParams#WRAP_CONTENT} and + * {@link android.view.ViewGroup.LayoutParams#MATCH_PARENT}. + * + * @return a PopupDimensions object + */ + // Called at first to understand which dimensions to use for the popup. + @NonNull + protected PopupDimensions getPopupDimensions() { + return new PopupDimensions(); + } + + /** + * Perform firther initialization here. Called after {@link #getView()}, + * each time the popup is shown. + */ + protected abstract void onViewShown(); + + /** + * Called to update the view to filter results with the query. + * It is called any time the popup is shown, and any time the text changes and query is updated. + * + * @param query query from the edit text, to filter our results + */ + protected abstract void onQuery(@Nullable CharSequence query); + + /** + * Called when the popup is hidden, to release resources. + */ + protected abstract void onViewHidden(); + + /** + * @return this presenter context + */ + @NonNull + protected final Context getContext() { + return context; + } + + /** + * @return whether we are showing currently + */ + @SuppressWarnings("unused") + protected final boolean isShowing() { + return isShowing; + } + + final void showView() { + isShowing = true; + onViewShown(); + } + + final void hideView() { + isShowing = false; + onViewHidden(); + } + + public interface ClickProvider { + void click(@NonNull T item); + } + + /** + * Provides width, height, maxWidth and maxHeight for the popup. + * @see #getPopupDimensions() + */ + @SuppressWarnings("WeakerAccess") + public static class PopupDimensions { + public int width = ViewGroup.LayoutParams.WRAP_CONTENT; + public int height = ViewGroup.LayoutParams.WRAP_CONTENT; + public int maxWidth = Integer.MAX_VALUE; + public int maxHeight = Integer.MAX_VALUE; + } +} diff --git a/library/external/autocomplete/src/main/java/com/otaliastudios/autocomplete/CharPolicy.java b/library/external/autocomplete/src/main/java/com/otaliastudios/autocomplete/CharPolicy.java new file mode 100644 index 0000000000..9c94983317 --- /dev/null +++ b/library/external/autocomplete/src/main/java/com/otaliastudios/autocomplete/CharPolicy.java @@ -0,0 +1,184 @@ +package com.otaliastudios.autocomplete; + +import android.text.Spannable; +import android.text.Spanned; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + + +/** + * A special {@link AutocompletePolicy} for cases when you want to trigger the popup when a + * certain character is shown. + * + * For instance, this might be the case for hashtags ('#') or usernames ('@') or whatever you wish. + * Passing this to {@link Autocomplete.Builder} ensures the following behavior (assuming '@'): + * - text "@john" : presenter will be passed the query "john" + * - text "You should see this @j" : presenter will be passed the query "j" + * - text "You should see this @john @m" : presenter will be passed the query "m" + */ +public class CharPolicy implements AutocompletePolicy { + + private final static String TAG = CharPolicy.class.getSimpleName(); + private final static boolean DEBUG = false; + + private static void log(@NonNull String log) { + if (DEBUG) Log.e(TAG, log); + } + + private final char CH; + private final int[] INT = new int[2]; + private boolean needSpaceBefore = true; + + /** + * Constructs a char policy for the given character. + * + * @param trigger the triggering character. + */ + public CharPolicy(char trigger) { + CH = trigger; + } + + /** + * Constructs a char policy for the given character. + * You can choose whether a whitespace is needed before 'trigger'. + * + * @param trigger the triggering character. + * @param needSpaceBefore whether we need a space before trigger + */ + @SuppressWarnings("unused") + public CharPolicy(char trigger, boolean needSpaceBefore) { + CH = trigger; + this.needSpaceBefore = needSpaceBefore; + } + + /** + * Can be overriden to understand which characters are valid. The default implementation + * returns true for any character except whitespaces. + * + * @param ch the character + * @return whether it's valid part of a query + */ + @SuppressWarnings("WeakerAccess") + protected boolean isValidChar(char ch) { + return !Character.isWhitespace(ch); + } + + @Nullable + private int[] checkText(@NonNull Spannable text, int cursorPos) { + final int spanEnd = cursorPos; + char last = 'x'; + cursorPos -= 1; // If the cursor is at the end, we will have cursorPos = length. Go back by 1. + while (cursorPos >= 0 && last != CH) { + char ch = text.charAt(cursorPos); + log("checkText: char is "+ch); + if (isValidChar(ch)) { + // We are going back + log("checkText: char is valid"); + cursorPos -= 1; + last = ch; + } else { + // We got a whitespace before getting a CH. This is invalid. + log("checkText: char is not valid, returning NULL"); + return null; + } + } + cursorPos += 1; // + 1 because we end BEHIND the valid selection + + // Start checking. + if (cursorPos == 0 && last != CH) { + // We got to the start of the string, and no CH was encountered. Nothing to do. + log("checkText: got to start but no CH, returning NULL"); + return null; + } + + // Additional checks for cursorPos - 1 + if (cursorPos > 0 && needSpaceBefore) { + char ch = text.charAt(cursorPos-1); + if (!Character.isWhitespace(ch)) { + log("checkText: char before is not whitespace, returning NULL"); + return null; + } + } + + // All seems OK. + final int spanStart = cursorPos + 1; // + 1 because we want to exclude CH from the query + INT[0] = spanStart; + INT[1] = spanEnd; + log("checkText: found! cursorPos="+cursorPos); + log("checkText: found! spanStart="+spanStart); + log("checkText: found! spanEnd="+spanEnd); + return INT; + } + + @Override + public boolean shouldShowPopup(@NonNull Spannable text, int cursorPos) { + // Returning true if, right before cursorPos, we have a word starting with @. + log("shouldShowPopup: text is "+text); + log("shouldShowPopup: cursorPos is "+cursorPos); + int[] show = checkText(text, cursorPos); + if (show != null) { + text.setSpan(new QuerySpan(), show[0], show[1], Spanned.SPAN_INCLUSIVE_INCLUSIVE); + return true; + } + log("shouldShowPopup: returning false"); + return false; + } + + @Override + public boolean shouldDismissPopup(@NonNull Spannable text, int cursorPos) { + log("shouldDismissPopup: text is "+text); + log("shouldDismissPopup: cursorPos is "+cursorPos); + boolean dismiss = checkText(text, cursorPos) == null; + log("shouldDismissPopup: returning "+dismiss); + return dismiss; + } + + @NonNull + @Override + public CharSequence getQuery(@NonNull Spannable text) { + QuerySpan[] span = text.getSpans(0, text.length(), QuerySpan.class); + if (span == null || span.length == 0) { + // Should never happen. + log("getQuery: there's no span!"); + return ""; + } + log("getQuery: found spans: "+span.length); + QuerySpan sp = span[0]; + log("getQuery: span start is "+text.getSpanStart(sp)); + log("getQuery: span end is "+text.getSpanEnd(sp)); + CharSequence seq = text.subSequence(text.getSpanStart(sp), text.getSpanEnd(sp)); + log("getQuery: returning "+seq); + return seq; + } + + + @Override + public void onDismiss(@NonNull Spannable text) { + // Remove any span added by shouldShow. Should be useless, but anyway. + QuerySpan[] span = text.getSpans(0, text.length(), QuerySpan.class); + for (QuerySpan s : span) { + text.removeSpan(s); + } + } + + private static class QuerySpan {} + + /** + * Returns the current query out of the given Spannable. + * @param text the anchor text + * @return an int[] with query start and query end positions + */ + @Nullable + public static int[] getQueryRange(@NonNull Spannable text) { + QuerySpan[] span = text.getSpans(0, text.length(), QuerySpan.class); + if (span == null || span.length == 0) return null; + if (span.length > 1) { + // Won't happen + log("getQueryRange: ERR: MORE THAN ONE QuerySpan."); + } + QuerySpan sp = span[0]; + return new int[]{text.getSpanStart(sp), text.getSpanEnd(sp)}; + } +} diff --git a/library/external/autocomplete/src/main/java/com/otaliastudios/autocomplete/RecyclerViewPresenter.java b/library/external/autocomplete/src/main/java/com/otaliastudios/autocomplete/RecyclerViewPresenter.java new file mode 100644 index 0000000000..1338ec7cac --- /dev/null +++ b/library/external/autocomplete/src/main/java/com/otaliastudios/autocomplete/RecyclerViewPresenter.java @@ -0,0 +1,152 @@ +package com.otaliastudios.autocomplete; + +import android.content.Context; +import android.database.DataSetObserver; +import android.view.ViewGroup; + +import androidx.annotation.CallSuper; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +/** + * Simple {@link AutocompletePresenter} implementation that hosts a {@link RecyclerView}. + * Supports {@link android.view.ViewGroup.LayoutParams#WRAP_CONTENT} natively. + * The only contract is to + * + * - provide a {@link RecyclerView.Adapter} in {@link #instantiateAdapter()} + * - call {@link #dispatchClick(Object)} when an object is clicked + * - update your data during {@link #onQuery(CharSequence)} + * + * @param your model object (the object displayed by the list) + */ +public abstract class RecyclerViewPresenter extends AutocompletePresenter { + + private RecyclerView recycler; + private ClickProvider clicks; + private Observer observer; + + public RecyclerViewPresenter(@NonNull Context context) { + super(context); + } + + @Override + protected final void registerClickProvider(@NonNull ClickProvider provider) { + this.clicks = provider; + } + + @Override + protected final void registerDataSetObserver(@NonNull DataSetObserver observer) { + this.observer = new Observer(observer); + } + + @NonNull + @Override + protected ViewGroup getView() { + recycler = new RecyclerView(getContext()); + RecyclerView.Adapter adapter = instantiateAdapter(); + recycler.setAdapter(adapter); + recycler.setLayoutManager(instantiateLayoutManager()); + if (observer != null) { + adapter.registerAdapterDataObserver(observer); + observer = null; + } + return recycler; + } + + @Override + protected void onViewShown() {} + + @CallSuper + @Override + protected void onViewHidden() { + recycler = null; + observer = null; + } + + @SuppressWarnings("unused") + @Nullable + protected final RecyclerView getRecyclerView() { + return recycler; + } + + /** + * Dispatch click event to {@link AutocompleteCallback}. + * Should be called when items are clicked. + * + * @param item the clicked item. + */ + protected final void dispatchClick(@NonNull T item) { + if (clicks != null) clicks.click(item); + } + + /** + * Request that the popup should recompute its dimensions based on a recent change in + * the view being displayed. + * + * This is already managed internally for {@link RecyclerView} events. + * Only use it for changes in other views that you have added to the popup, + * and only if one of the dimensions for the popup is WRAP_CONTENT . + */ + @SuppressWarnings("unused") + protected final void dispatchLayoutChange() { + if (observer != null) observer.onChanged(); + } + + /** + * Provide an adapter for the recycler. + * This should be a fresh instance every time this is called. + * + * @return a new adapter. + */ + @NonNull + protected abstract RecyclerView.Adapter instantiateAdapter(); + + /** + * Provides a layout manager for the recycler. + * This should be a fresh instance every time this is called. + * Defaults to a vertical LinearLayoutManager, which is guaranteed to work well. + * + * @return a new layout manager. + */ + @SuppressWarnings("WeakerAccess") + @NonNull + protected RecyclerView.LayoutManager instantiateLayoutManager() { + return new LinearLayoutManager(getContext(), LinearLayoutManager.VERTICAL, false); + } + + private final static class Observer extends RecyclerView.AdapterDataObserver { + + private DataSetObserver root; + + Observer(@NonNull DataSetObserver root) { + this.root = root; + } + + @Override + public void onChanged() { + root.onChanged(); + } + + @Override + public void onItemRangeChanged(int positionStart, int itemCount) { + root.onChanged(); + } + + @Override + public void onItemRangeChanged(int positionStart, int itemCount, Object payload) { + root.onChanged(); + } + + @Override + public void onItemRangeInserted(int positionStart, int itemCount) { + root.onChanged(); + } + + @Override + public void onItemRangeRemoved(int positionStart, int itemCount) { + root.onChanged(); + } + } +} diff --git a/library/external/barcodescanner/core/build.gradle b/library/external/barcodescanner/core/build.gradle new file mode 100644 index 0000000000..583b435dd7 --- /dev/null +++ b/library/external/barcodescanner/core/build.gradle @@ -0,0 +1,26 @@ +apply plugin: 'com.android.library' + +android { + namespace "me.dm7.barcodescanner.core" + compileSdk versions.compileSdk + + defaultConfig { + minSdk versions.minSdk + targetSdk versions.targetSdk + } + + compileOptions { + sourceCompatibility versions.sourceCompat + targetCompatibility versions.targetCompat + } +} + +dependencies { + implementation 'androidx.annotation:annotation-jvm:1.6.0' +} + +afterEvaluate { + tasks.findAll { it.name.startsWith("lint") }.each { + it.enabled = false + } +} diff --git a/library/external/barcodescanner/core/src/main/java/me/dm7/barcodescanner/core/BarcodeScannerView.java b/library/external/barcodescanner/core/src/main/java/me/dm7/barcodescanner/core/BarcodeScannerView.java new file mode 100644 index 0000000000..ecfba76d89 --- /dev/null +++ b/library/external/barcodescanner/core/src/main/java/me/dm7/barcodescanner/core/BarcodeScannerView.java @@ -0,0 +1,339 @@ +package me.dm7.barcodescanner.core; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Color; +import android.graphics.Rect; +import android.hardware.Camera; +import android.util.AttributeSet; +import android.view.Gravity; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.RelativeLayout; + +import androidx.annotation.ColorInt; + +public abstract class BarcodeScannerView extends FrameLayout implements Camera.PreviewCallback { + + private CameraWrapper mCameraWrapper; + private CameraPreview mPreview; + private IViewFinder mViewFinderView; + private Rect mFramingRectInPreview; + private CameraHandlerThread mCameraHandlerThread; + private Boolean mFlashState; + private boolean mAutofocusState = true; + private boolean mShouldScaleToFill = true; + + private boolean mIsLaserEnabled = true; + @ColorInt private int mLaserColor = getResources().getColor(R.color.viewfinder_laser); + @ColorInt private int mBorderColor = getResources().getColor(R.color.viewfinder_border); + private int mMaskColor = getResources().getColor(R.color.viewfinder_mask); + private int mBorderWidth = getResources().getInteger(R.integer.viewfinder_border_width); + private int mBorderLength = getResources().getInteger(R.integer.viewfinder_border_length); + private boolean mRoundedCorner = false; + private int mCornerRadius = 0; + private boolean mSquaredFinder = false; + private float mBorderAlpha = 1.0f; + private int mViewFinderOffset = 0; + private float mAspectTolerance = 0.1f; + + public BarcodeScannerView(Context context) { + super(context); + init(); + } + + public BarcodeScannerView(Context context, AttributeSet attributeSet) { + super(context, attributeSet); + + TypedArray a = context.getTheme().obtainStyledAttributes( + attributeSet, + R.styleable.BarcodeScannerView, + 0, 0); + + try { + setShouldScaleToFill(a.getBoolean(R.styleable.BarcodeScannerView_shouldScaleToFill, true)); + mIsLaserEnabled = a.getBoolean(R.styleable.BarcodeScannerView_laserEnabled, mIsLaserEnabled); + mLaserColor = a.getColor(R.styleable.BarcodeScannerView_laserColor, mLaserColor); + mBorderColor = a.getColor(R.styleable.BarcodeScannerView_borderColor, mBorderColor); + mMaskColor = a.getColor(R.styleable.BarcodeScannerView_maskColor, mMaskColor); + mBorderWidth = a.getDimensionPixelSize(R.styleable.BarcodeScannerView_borderWidth, mBorderWidth); + mBorderLength = a.getDimensionPixelSize(R.styleable.BarcodeScannerView_borderLength, mBorderLength); + + mRoundedCorner = a.getBoolean(R.styleable.BarcodeScannerView_roundedCorner, mRoundedCorner); + mCornerRadius = a.getDimensionPixelSize(R.styleable.BarcodeScannerView_cornerRadius, mCornerRadius); + mSquaredFinder = a.getBoolean(R.styleable.BarcodeScannerView_squaredFinder, mSquaredFinder); + mBorderAlpha = a.getFloat(R.styleable.BarcodeScannerView_borderAlpha, mBorderAlpha); + mViewFinderOffset = a.getDimensionPixelSize(R.styleable.BarcodeScannerView_finderOffset, mViewFinderOffset); + } finally { + a.recycle(); + } + + init(); + } + + private void init() { + mViewFinderView = createViewFinderView(getContext()); + } + + public final void setupLayout(CameraWrapper cameraWrapper) { + removeAllViews(); + + mPreview = new CameraPreview(getContext(), cameraWrapper, this); + mPreview.setAspectTolerance(mAspectTolerance); + mPreview.setShouldScaleToFill(mShouldScaleToFill); + if (!mShouldScaleToFill) { + RelativeLayout relativeLayout = new RelativeLayout(getContext()); + relativeLayout.setGravity(Gravity.CENTER); + relativeLayout.setBackgroundColor(Color.BLACK); + relativeLayout.addView(mPreview); + addView(relativeLayout); + } else { + addView(mPreview); + } + + if (mViewFinderView instanceof View) { + addView((View) mViewFinderView); + } else { + throw new IllegalArgumentException("IViewFinder object returned by " + + "'createViewFinderView()' should be instance of android.view.View"); + } + } + + /** + *

Method that creates view that represents visual appearance of a barcode scanner

+ *

Override it to provide your own view for visual appearance of a barcode scanner

+ * + * @param context {@link Context} + * @return {@link android.view.View} that implements {@link ViewFinderView} + */ + protected IViewFinder createViewFinderView(Context context) { + ViewFinderView viewFinderView = new ViewFinderView(context); + viewFinderView.setBorderColor(mBorderColor); + viewFinderView.setLaserColor(mLaserColor); + viewFinderView.setLaserEnabled(mIsLaserEnabled); + viewFinderView.setBorderStrokeWidth(mBorderWidth); + viewFinderView.setBorderLineLength(mBorderLength); + viewFinderView.setMaskColor(mMaskColor); + + viewFinderView.setBorderCornerRounded(mRoundedCorner); + viewFinderView.setBorderCornerRadius(mCornerRadius); + viewFinderView.setSquareViewFinder(mSquaredFinder); + viewFinderView.setViewFinderOffset(mViewFinderOffset); + return viewFinderView; + } + + public void setLaserColor(int laserColor) { + mLaserColor = laserColor; + mViewFinderView.setLaserColor(mLaserColor); + mViewFinderView.setupViewFinder(); + } + public void setMaskColor(int maskColor) { + mMaskColor = maskColor; + mViewFinderView.setMaskColor(mMaskColor); + mViewFinderView.setupViewFinder(); + } + public void setBorderColor(int borderColor) { + mBorderColor = borderColor; + mViewFinderView.setBorderColor(mBorderColor); + mViewFinderView.setupViewFinder(); + } + public void setBorderStrokeWidth(int borderStrokeWidth) { + mBorderWidth = borderStrokeWidth; + mViewFinderView.setBorderStrokeWidth(mBorderWidth); + mViewFinderView.setupViewFinder(); + } + public void setBorderLineLength(int borderLineLength) { + mBorderLength = borderLineLength; + mViewFinderView.setBorderLineLength(mBorderLength); + mViewFinderView.setupViewFinder(); + } + public void setLaserEnabled(boolean isLaserEnabled) { + mIsLaserEnabled = isLaserEnabled; + mViewFinderView.setLaserEnabled(mIsLaserEnabled); + mViewFinderView.setupViewFinder(); + } + public void setIsBorderCornerRounded(boolean isBorderCornerRounded) { + mRoundedCorner = isBorderCornerRounded; + mViewFinderView.setBorderCornerRounded(mRoundedCorner); + mViewFinderView.setupViewFinder(); + } + public void setBorderCornerRadius(int borderCornerRadius) { + mCornerRadius = borderCornerRadius; + mViewFinderView.setBorderCornerRadius(mCornerRadius); + mViewFinderView.setupViewFinder(); + } + public void setSquareViewFinder(boolean isSquareViewFinder) { + mSquaredFinder = isSquareViewFinder; + mViewFinderView.setSquareViewFinder(mSquaredFinder); + mViewFinderView.setupViewFinder(); + } + public void setBorderAlpha(float borderAlpha) { + mBorderAlpha = borderAlpha; + mViewFinderView.setBorderAlpha(mBorderAlpha); + mViewFinderView.setupViewFinder(); + } + + public void startCamera(int cameraId) { + if(mCameraHandlerThread == null) { + mCameraHandlerThread = new CameraHandlerThread(this); + } + mCameraHandlerThread.startCamera(cameraId); + } + + public void setupCameraPreview(CameraWrapper cameraWrapper) { + mCameraWrapper = cameraWrapper; + if(mCameraWrapper != null) { + setupLayout(mCameraWrapper); + mViewFinderView.setupViewFinder(); + if(mFlashState != null) { + setFlash(mFlashState); + } + setAutoFocus(mAutofocusState); + } + } + + public void startCamera() { + startCamera(CameraUtils.getDefaultCameraId()); + } + + public void stopCamera() { + if(mCameraWrapper != null) { + mPreview.stopCameraPreview(); + mPreview.setCamera(null, null); + mCameraWrapper.mCamera.release(); + mCameraWrapper = null; + } + if(mCameraHandlerThread != null) { + mCameraHandlerThread.quit(); + mCameraHandlerThread = null; + } + } + + public void stopCameraPreview() { + if(mPreview != null) { + mPreview.stopCameraPreview(); + } + } + + protected void resumeCameraPreview() { + if(mPreview != null) { + mPreview.showCameraPreview(); + } + } + + public synchronized Rect getFramingRectInPreview(int previewWidth, int previewHeight) { + if (mFramingRectInPreview == null) { + Rect framingRect = mViewFinderView.getFramingRect(); + int viewFinderViewWidth = mViewFinderView.getWidth(); + int viewFinderViewHeight = mViewFinderView.getHeight(); + if (framingRect == null || viewFinderViewWidth == 0 || viewFinderViewHeight == 0) { + return null; + } + + Rect rect = new Rect(framingRect); + + if(previewWidth < viewFinderViewWidth) { + rect.left = rect.left * previewWidth / viewFinderViewWidth; + rect.right = rect.right * previewWidth / viewFinderViewWidth; + } + + if(previewHeight < viewFinderViewHeight) { + rect.top = rect.top * previewHeight / viewFinderViewHeight; + rect.bottom = rect.bottom * previewHeight / viewFinderViewHeight; + } + + mFramingRectInPreview = rect; + } + return mFramingRectInPreview; + } + + public void setFlash(boolean flag) { + mFlashState = flag; + if(mCameraWrapper != null && CameraUtils.isFlashSupported(mCameraWrapper.mCamera)) { + + Camera.Parameters parameters = mCameraWrapper.mCamera.getParameters(); + if(flag) { + if(parameters.getFlashMode().equals(Camera.Parameters.FLASH_MODE_TORCH)) { + return; + } + parameters.setFlashMode(Camera.Parameters.FLASH_MODE_TORCH); + } else { + if(parameters.getFlashMode().equals(Camera.Parameters.FLASH_MODE_OFF)) { + return; + } + parameters.setFlashMode(Camera.Parameters.FLASH_MODE_OFF); + } + mCameraWrapper.mCamera.setParameters(parameters); + } + } + + public boolean getFlash() { + if(mCameraWrapper != null && CameraUtils.isFlashSupported(mCameraWrapper.mCamera)) { + Camera.Parameters parameters = mCameraWrapper.mCamera.getParameters(); + if(parameters.getFlashMode().equals(Camera.Parameters.FLASH_MODE_TORCH)) { + return true; + } else { + return false; + } + } + return false; + } + + public void toggleFlash() { + if(mCameraWrapper != null && CameraUtils.isFlashSupported(mCameraWrapper.mCamera)) { + Camera.Parameters parameters = mCameraWrapper.mCamera.getParameters(); + if(parameters.getFlashMode().equals(Camera.Parameters.FLASH_MODE_TORCH)) { + parameters.setFlashMode(Camera.Parameters.FLASH_MODE_OFF); + } else { + parameters.setFlashMode(Camera.Parameters.FLASH_MODE_TORCH); + } + mCameraWrapper.mCamera.setParameters(parameters); + } + } + + public void setAutoFocus(boolean state) { + mAutofocusState = state; + if(mPreview != null) { + mPreview.setAutoFocus(state); + } + } + + public void setShouldScaleToFill(boolean shouldScaleToFill) { + mShouldScaleToFill = shouldScaleToFill; + } + + public void setAspectTolerance(float aspectTolerance) { + mAspectTolerance = aspectTolerance; + } + + public byte[] getRotatedData(byte[] data, Camera camera) { + Camera.Parameters parameters = camera.getParameters(); + Camera.Size size = parameters.getPreviewSize(); + int width = size.width; + int height = size.height; + + int rotationCount = getRotationCount(); + + if(rotationCount == 1 || rotationCount == 3) { + for (int i = 0; i < rotationCount; i++) { + byte[] rotatedData = new byte[data.length]; + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) + rotatedData[x * height + height - y - 1] = data[x + y * width]; + } + data = rotatedData; + int tmp = width; + width = height; + height = tmp; + } + } + + return data; + } + + public int getRotationCount() { + int displayOrientation = mPreview.getDisplayOrientation(); + return displayOrientation / 90; + } +} + diff --git a/library/external/barcodescanner/core/src/main/java/me/dm7/barcodescanner/core/CameraHandlerThread.java b/library/external/barcodescanner/core/src/main/java/me/dm7/barcodescanner/core/CameraHandlerThread.java new file mode 100644 index 0000000000..2d4bcee7bf --- /dev/null +++ b/library/external/barcodescanner/core/src/main/java/me/dm7/barcodescanner/core/CameraHandlerThread.java @@ -0,0 +1,37 @@ +package me.dm7.barcodescanner.core; + + +import android.hardware.Camera; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Looper; + +// This code is mostly based on the top answer here: http://stackoverflow.com/questions/18149964/best-use-of-handlerthread-over-other-similar-classes +public class CameraHandlerThread extends HandlerThread { + private static final String LOG_TAG = "CameraHandlerThread"; + + private BarcodeScannerView mScannerView; + + public CameraHandlerThread(BarcodeScannerView scannerView) { + super("CameraHandlerThread"); + mScannerView = scannerView; + start(); + } + + public void startCamera(final int cameraId) { + Handler localHandler = new Handler(getLooper()); + localHandler.post(new Runnable() { + @Override + public void run() { + final Camera camera = CameraUtils.getCameraInstance(cameraId); + Handler mainHandler = new Handler(Looper.getMainLooper()); + mainHandler.post(new Runnable() { + @Override + public void run() { + mScannerView.setupCameraPreview(CameraWrapper.getWrapper(camera, cameraId)); + } + }); + } + }); + } +} diff --git a/library/external/barcodescanner/core/src/main/java/me/dm7/barcodescanner/core/CameraPreview.java b/library/external/barcodescanner/core/src/main/java/me/dm7/barcodescanner/core/CameraPreview.java new file mode 100644 index 0000000000..b066e25b2c --- /dev/null +++ b/library/external/barcodescanner/core/src/main/java/me/dm7/barcodescanner/core/CameraPreview.java @@ -0,0 +1,312 @@ +package me.dm7.barcodescanner.core; + +import android.content.Context; +import android.content.res.Configuration; +import android.graphics.Point; +import android.hardware.Camera; +import android.os.Handler; +import android.util.AttributeSet; +import android.util.Log; +import android.view.Display; +import android.view.Surface; +import android.view.SurfaceHolder; +import android.view.SurfaceView; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowManager; + +import java.util.List; + +public class CameraPreview extends SurfaceView implements SurfaceHolder.Callback { + private static final String TAG = "CameraPreview"; + + private CameraWrapper mCameraWrapper; + private Handler mAutoFocusHandler; + private boolean mPreviewing = true; + private boolean mAutoFocus = true; + private boolean mSurfaceCreated = false; + private boolean mShouldScaleToFill = true; + private Camera.PreviewCallback mPreviewCallback; + private float mAspectTolerance = 0.1f; + + public CameraPreview(Context context, CameraWrapper cameraWrapper, Camera.PreviewCallback previewCallback) { + super(context); + init(cameraWrapper, previewCallback); + } + + public CameraPreview(Context context, AttributeSet attrs, CameraWrapper cameraWrapper, Camera.PreviewCallback previewCallback) { + super(context, attrs); + init(cameraWrapper, previewCallback); + } + + public void init(CameraWrapper cameraWrapper, Camera.PreviewCallback previewCallback) { + setCamera(cameraWrapper, previewCallback); + mAutoFocusHandler = new Handler(); + getHolder().addCallback(this); + getHolder().setType(SurfaceHolder.SURFACE_TYPE_PUSH_BUFFERS); + } + + public void setCamera(CameraWrapper cameraWrapper, Camera.PreviewCallback previewCallback) { + mCameraWrapper = cameraWrapper; + mPreviewCallback = previewCallback; + } + + public void setShouldScaleToFill(boolean scaleToFill) { + mShouldScaleToFill = scaleToFill; + } + + public void setAspectTolerance(float aspectTolerance) { + mAspectTolerance = aspectTolerance; + } + + @Override + public void surfaceCreated(SurfaceHolder surfaceHolder) { + mSurfaceCreated = true; + } + + @Override + public void surfaceChanged(SurfaceHolder surfaceHolder, int i, int i2, int i3) { + if(surfaceHolder.getSurface() == null) { + return; + } + stopCameraPreview(); + showCameraPreview(); + } + + @Override + public void surfaceDestroyed(SurfaceHolder surfaceHolder) { + mSurfaceCreated = false; + stopCameraPreview(); + } + + public void showCameraPreview() { + if(mCameraWrapper != null) { + try { + getHolder().addCallback(this); + mPreviewing = true; + setupCameraParameters(); + mCameraWrapper.mCamera.setPreviewDisplay(getHolder()); + mCameraWrapper.mCamera.setDisplayOrientation(getDisplayOrientation()); + mCameraWrapper.mCamera.setOneShotPreviewCallback(mPreviewCallback); + mCameraWrapper.mCamera.startPreview(); + if(mAutoFocus) { + if (mSurfaceCreated) { // check if surface created before using autofocus + safeAutoFocus(); + } else { + scheduleAutoFocus(); // wait 1 sec and then do check again + } + } + } catch (Exception e) { + Log.e(TAG, e.toString(), e); + } + } + } + + public void safeAutoFocus() { + try { + mCameraWrapper.mCamera.autoFocus(autoFocusCB); + } catch (RuntimeException re) { + // Horrible hack to deal with autofocus errors on Sony devices + // See https://github.com/dm77/barcodescanner/issues/7 for example + scheduleAutoFocus(); // wait 1 sec and then do check again + } + } + + public void stopCameraPreview() { + if(mCameraWrapper != null) { + try { + mPreviewing = false; + getHolder().removeCallback(this); + mCameraWrapper.mCamera.cancelAutoFocus(); + mCameraWrapper.mCamera.setOneShotPreviewCallback(null); + mCameraWrapper.mCamera.stopPreview(); + } catch(Exception e) { + Log.e(TAG, e.toString(), e); + } + } + } + + public void setupCameraParameters() { + Camera.Size optimalSize = getOptimalPreviewSize(); + Camera.Parameters parameters = mCameraWrapper.mCamera.getParameters(); + parameters.setPreviewSize(optimalSize.width, optimalSize.height); + mCameraWrapper.mCamera.setParameters(parameters); + adjustViewSize(optimalSize); + } + + private void adjustViewSize(Camera.Size cameraSize) { + Point previewSize = convertSizeToLandscapeOrientation(new Point(getWidth(), getHeight())); + float cameraRatio = ((float) cameraSize.width) / cameraSize.height; + float screenRatio = ((float) previewSize.x) / previewSize.y; + + if (screenRatio > cameraRatio) { + setViewSize((int) (previewSize.y * cameraRatio), previewSize.y); + } else { + setViewSize(previewSize.x, (int) (previewSize.x / cameraRatio)); + } + } + + @SuppressWarnings("SuspiciousNameCombination") + private Point convertSizeToLandscapeOrientation(Point size) { + if (getDisplayOrientation() % 180 == 0) { + return size; + } else { + return new Point(size.y, size.x); + } + } + + @SuppressWarnings("SuspiciousNameCombination") + private void setViewSize(int width, int height) { + ViewGroup.LayoutParams layoutParams = getLayoutParams(); + int tmpWidth; + int tmpHeight; + if (getDisplayOrientation() % 180 == 0) { + tmpWidth = width; + tmpHeight = height; + } else { + tmpWidth = height; + tmpHeight = width; + } + + if (mShouldScaleToFill) { + int parentWidth = ((View) getParent()).getWidth(); + int parentHeight = ((View) getParent()).getHeight(); + float ratioWidth = (float) parentWidth / (float) tmpWidth; + float ratioHeight = (float) parentHeight / (float) tmpHeight; + + float compensation; + + if (ratioWidth > ratioHeight) { + compensation = ratioWidth; + } else { + compensation = ratioHeight; + } + + tmpWidth = Math.round(tmpWidth * compensation); + tmpHeight = Math.round(tmpHeight * compensation); + } + + layoutParams.width = tmpWidth; + layoutParams.height = tmpHeight; + setLayoutParams(layoutParams); + } + + public int getDisplayOrientation() { + if (mCameraWrapper == null) { + //If we don't have a camera set there is no orientation so return dummy value + return 0; + } + + Camera.CameraInfo info = new Camera.CameraInfo(); + if(mCameraWrapper.mCameraId == -1) { + Camera.getCameraInfo(Camera.CameraInfo.CAMERA_FACING_BACK, info); + } else { + Camera.getCameraInfo(mCameraWrapper.mCameraId, info); + } + + WindowManager wm = (WindowManager) getContext().getSystemService(Context.WINDOW_SERVICE); + Display display = wm.getDefaultDisplay(); + + int rotation = display.getRotation(); + int degrees = 0; + switch (rotation) { + case Surface.ROTATION_0: degrees = 0; break; + case Surface.ROTATION_90: degrees = 90; break; + case Surface.ROTATION_180: degrees = 180; break; + case Surface.ROTATION_270: degrees = 270; break; + } + + int result; + if (info.facing == Camera.CameraInfo.CAMERA_FACING_FRONT) { + result = (info.orientation + degrees) % 360; + result = (360 - result) % 360; // compensate the mirror + } else { // back-facing + result = (info.orientation - degrees + 360) % 360; + } + return result; + } + + private Camera.Size getOptimalPreviewSize() { + if(mCameraWrapper == null) { + return null; + } + + List sizes = mCameraWrapper.mCamera.getParameters().getSupportedPreviewSizes(); + int w = getWidth(); + int h = getHeight(); + if (DisplayUtils.getScreenOrientation(getContext()) == Configuration.ORIENTATION_PORTRAIT) { + int portraitWidth = h; + h = w; + w = portraitWidth; + } + + double targetRatio = (double) w / h; + if (sizes == null) return null; + + Camera.Size optimalSize = null; + double minDiff = Double.MAX_VALUE; + + int targetHeight = h; + + // Try to find an size match aspect ratio and size + for (Camera.Size size : sizes) { + double ratio = (double) size.width / size.height; + if (Math.abs(ratio - targetRatio) > mAspectTolerance) continue; + if (Math.abs(size.height - targetHeight) < minDiff) { + optimalSize = size; + minDiff = Math.abs(size.height - targetHeight); + } + } + + // Cannot find the one match the aspect ratio, ignore the requirement + if (optimalSize == null) { + minDiff = Double.MAX_VALUE; + for (Camera.Size size : sizes) { + if (Math.abs(size.height - targetHeight) < minDiff) { + optimalSize = size; + minDiff = Math.abs(size.height - targetHeight); + } + } + } + return optimalSize; + } + + public void setAutoFocus(boolean state) { + if(mCameraWrapper != null && mPreviewing) { + if(state == mAutoFocus) { + return; + } + mAutoFocus = state; + if(mAutoFocus) { + if (mSurfaceCreated) { // check if surface created before using autofocus + Log.v(TAG, "Starting autofocus"); + safeAutoFocus(); + } else { + scheduleAutoFocus(); // wait 1 sec and then do check again + } + } else { + Log.v(TAG, "Cancelling autofocus"); + mCameraWrapper.mCamera.cancelAutoFocus(); + } + } + } + + private Runnable doAutoFocus = new Runnable() { + public void run() { + if(mCameraWrapper != null && mPreviewing && mAutoFocus && mSurfaceCreated) { + safeAutoFocus(); + } + } + }; + + // Mimic continuous auto-focusing + Camera.AutoFocusCallback autoFocusCB = new Camera.AutoFocusCallback() { + public void onAutoFocus(boolean success, Camera camera) { + scheduleAutoFocus(); + } + }; + + private void scheduleAutoFocus() { + mAutoFocusHandler.postDelayed(doAutoFocus, 1000); + } +} diff --git a/library/external/barcodescanner/core/src/main/java/me/dm7/barcodescanner/core/CameraUtils.java b/library/external/barcodescanner/core/src/main/java/me/dm7/barcodescanner/core/CameraUtils.java new file mode 100644 index 0000000000..599bd5fa48 --- /dev/null +++ b/library/external/barcodescanner/core/src/main/java/me/dm7/barcodescanner/core/CameraUtils.java @@ -0,0 +1,63 @@ +package me.dm7.barcodescanner.core; + +import android.hardware.Camera; + +import java.util.List; + +public class CameraUtils { + /** A safe way to get an instance of the Camera object. */ + public static Camera getCameraInstance() { + return getCameraInstance(getDefaultCameraId()); + } + + /** Favor back-facing camera by default. If none exists, fallback to whatever camera is available **/ + public static int getDefaultCameraId() { + int numberOfCameras = Camera.getNumberOfCameras(); + Camera.CameraInfo cameraInfo = new Camera.CameraInfo(); + int defaultCameraId = -1; + for (int i = 0; i < numberOfCameras; i++) { + defaultCameraId = i; + Camera.getCameraInfo(i, cameraInfo); + if (cameraInfo.facing == Camera.CameraInfo.CAMERA_FACING_BACK) { + return i; + } + } + return defaultCameraId; + } + + /** A safe way to get an instance of the Camera object. */ + public static Camera getCameraInstance(int cameraId) { + Camera c = null; + try { + if(cameraId == -1) { + c = Camera.open(); // attempt to get a Camera instance + } else { + c = Camera.open(cameraId); // attempt to get a Camera instance + } + } + catch (Exception e) { + // Camera is not available (in use or does not exist) + } + return c; // returns null if camera is unavailable + } + + public static boolean isFlashSupported(Camera camera) { + /* Credits: Top answer at http://stackoverflow.com/a/19599365/868173 */ + if (camera != null) { + Camera.Parameters parameters = camera.getParameters(); + + if (parameters.getFlashMode() == null) { + return false; + } + + List supportedFlashModes = parameters.getSupportedFlashModes(); + if (supportedFlashModes == null || supportedFlashModes.isEmpty() || supportedFlashModes.size() == 1 && supportedFlashModes.get(0).equals(Camera.Parameters.FLASH_MODE_OFF)) { + return false; + } + } else { + return false; + } + + return true; + } +} \ No newline at end of file diff --git a/library/external/barcodescanner/core/src/main/java/me/dm7/barcodescanner/core/CameraWrapper.java b/library/external/barcodescanner/core/src/main/java/me/dm7/barcodescanner/core/CameraWrapper.java new file mode 100644 index 0000000000..49759ae0f9 --- /dev/null +++ b/library/external/barcodescanner/core/src/main/java/me/dm7/barcodescanner/core/CameraWrapper.java @@ -0,0 +1,23 @@ +package me.dm7.barcodescanner.core; + +import android.hardware.Camera; + +import androidx.annotation.NonNull; + +public class CameraWrapper { + public final Camera mCamera; + public final int mCameraId; + + private CameraWrapper(@NonNull Camera camera, int cameraId) { + this.mCamera = camera; + this.mCameraId = cameraId; + } + + public static CameraWrapper getWrapper(Camera camera, int cameraId) { + if (camera == null) { + return null; + } else { + return new CameraWrapper(camera, cameraId); + } + } +} diff --git a/library/external/barcodescanner/core/src/main/java/me/dm7/barcodescanner/core/DisplayUtils.java b/library/external/barcodescanner/core/src/main/java/me/dm7/barcodescanner/core/DisplayUtils.java new file mode 100644 index 0000000000..6c47312221 --- /dev/null +++ b/library/external/barcodescanner/core/src/main/java/me/dm7/barcodescanner/core/DisplayUtils.java @@ -0,0 +1,41 @@ +package me.dm7.barcodescanner.core; + +import android.content.Context; +import android.content.res.Configuration; +import android.graphics.Point; +import android.view.Display; +import android.view.WindowManager; + +public class DisplayUtils { + public static Point getScreenResolution(Context context) { + WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); + Display display = wm.getDefaultDisplay(); + Point screenResolution = new Point(); + if (android.os.Build.VERSION.SDK_INT >= 13) { + display.getSize(screenResolution); + } else { + screenResolution.set(display.getWidth(), display.getHeight()); + } + + return screenResolution; + } + + public static int getScreenOrientation(Context context) + { + WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); + Display display = wm.getDefaultDisplay(); + + int orientation = Configuration.ORIENTATION_UNDEFINED; + if(display.getWidth()==display.getHeight()){ + orientation = Configuration.ORIENTATION_SQUARE; + } else{ + if(display.getWidth() < display.getHeight()){ + orientation = Configuration.ORIENTATION_PORTRAIT; + }else { + orientation = Configuration.ORIENTATION_LANDSCAPE; + } + } + return orientation; + } + +} diff --git a/library/external/barcodescanner/core/src/main/java/me/dm7/barcodescanner/core/IViewFinder.java b/library/external/barcodescanner/core/src/main/java/me/dm7/barcodescanner/core/IViewFinder.java new file mode 100644 index 0000000000..a6caaa4cb6 --- /dev/null +++ b/library/external/barcodescanner/core/src/main/java/me/dm7/barcodescanner/core/IViewFinder.java @@ -0,0 +1,53 @@ +package me.dm7.barcodescanner.core; + +import android.graphics.Rect; + +public interface IViewFinder { + + void setLaserColor(int laserColor); + void setMaskColor(int maskColor); + void setBorderColor(int borderColor); + void setBorderStrokeWidth(int borderStrokeWidth); + void setBorderLineLength(int borderLineLength); + void setLaserEnabled(boolean isLaserEnabled); + + void setBorderCornerRounded(boolean isBorderCornersRounded); + void setBorderAlpha(float alpha); + void setBorderCornerRadius(int borderCornersRadius); + void setViewFinderOffset(int offset); + void setSquareViewFinder(boolean isSquareViewFinder); + /** + * Method that executes when Camera preview is starting. + * It is recommended to update framing rect here and invalidate view after that.
+ * For example see: {@link ViewFinderView#setupViewFinder()} + */ + void setupViewFinder(); + + /** + * Provides {@link Rect} that identifies area where barcode scanner can detect visual codes + *

Note: This rect is a area representation in absolute pixel values.
+ * For example:
+ * If View's size is 1024x800 so framing rect might be 500x400

+ * + * @return {@link Rect} that identifies barcode scanner area + */ + Rect getFramingRect(); + + /** + * Width of a {@link android.view.View} that implements this interface + *

Note: this is already implemented in {@link android.view.View}, + * so you don't need to override method and provide your implementation

+ * + * @return width of a view + */ + int getWidth(); + + /** + * Height of a {@link android.view.View} that implements this interface + *

Note: this is already implemented in {@link android.view.View}, + * so you don't need to override method and provide your implementation

+ * + * @return height of a view + */ + int getHeight(); +} diff --git a/library/external/barcodescanner/core/src/main/java/me/dm7/barcodescanner/core/ViewFinderView.java b/library/external/barcodescanner/core/src/main/java/me/dm7/barcodescanner/core/ViewFinderView.java new file mode 100644 index 0000000000..307a8a42b4 --- /dev/null +++ b/library/external/barcodescanner/core/src/main/java/me/dm7/barcodescanner/core/ViewFinderView.java @@ -0,0 +1,259 @@ +package me.dm7.barcodescanner.core; + +import android.content.Context; +import android.content.res.Configuration; +import android.graphics.Canvas; +import android.graphics.CornerPathEffect; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.Point; +import android.graphics.Rect; +import android.util.AttributeSet; +import android.view.View; + +public class ViewFinderView extends View implements IViewFinder { + private static final String TAG = "ViewFinderView"; + + private Rect mFramingRect; + + private static final float PORTRAIT_WIDTH_RATIO = 6f/8; + private static final float PORTRAIT_WIDTH_HEIGHT_RATIO = 0.75f; + + private static final float LANDSCAPE_HEIGHT_RATIO = 5f/8; + private static final float LANDSCAPE_WIDTH_HEIGHT_RATIO = 1.4f; + private static final int MIN_DIMENSION_DIFF = 50; + + private static final float DEFAULT_SQUARE_DIMENSION_RATIO = 5f / 8; + + private static final int[] SCANNER_ALPHA = {0, 64, 128, 192, 255, 192, 128, 64}; + private int scannerAlpha; + private static final int POINT_SIZE = 10; + private static final long ANIMATION_DELAY = 80l; + + private final int mDefaultLaserColor = getResources().getColor(R.color.viewfinder_laser); + private final int mDefaultMaskColor = getResources().getColor(R.color.viewfinder_mask); + private final int mDefaultBorderColor = getResources().getColor(R.color.viewfinder_border); + private final int mDefaultBorderStrokeWidth = getResources().getInteger(R.integer.viewfinder_border_width); + private final int mDefaultBorderLineLength = getResources().getInteger(R.integer.viewfinder_border_length); + + protected Paint mLaserPaint; + protected Paint mFinderMaskPaint; + protected Paint mBorderPaint; + protected int mBorderLineLength; + protected boolean mSquareViewFinder; + private boolean mIsLaserEnabled; + private float mBordersAlpha; + private int mViewFinderOffset = 0; + + public ViewFinderView(Context context) { + super(context); + init(); + } + + public ViewFinderView(Context context, AttributeSet attributeSet) { + super(context, attributeSet); + init(); + } + + private void init() { + //set up laser paint + mLaserPaint = new Paint(); + mLaserPaint.setColor(mDefaultLaserColor); + mLaserPaint.setStyle(Paint.Style.FILL); + + //finder mask paint + mFinderMaskPaint = new Paint(); + mFinderMaskPaint.setColor(mDefaultMaskColor); + + //border paint + mBorderPaint = new Paint(); + mBorderPaint.setColor(mDefaultBorderColor); + mBorderPaint.setStyle(Paint.Style.STROKE); + mBorderPaint.setStrokeWidth(mDefaultBorderStrokeWidth); + mBorderPaint.setAntiAlias(true); + + mBorderLineLength = mDefaultBorderLineLength; + } + + @Override + public void setLaserColor(int laserColor) { + mLaserPaint.setColor(laserColor); + } + + @Override + public void setMaskColor(int maskColor) { + mFinderMaskPaint.setColor(maskColor); + } + + @Override + public void setBorderColor(int borderColor) { + mBorderPaint.setColor(borderColor); + } + + @Override + public void setBorderStrokeWidth(int borderStrokeWidth) { + mBorderPaint.setStrokeWidth(borderStrokeWidth); + } + + @Override + public void setBorderLineLength(int borderLineLength) { + mBorderLineLength = borderLineLength; + } + + @Override + public void setLaserEnabled(boolean isLaserEnabled) { mIsLaserEnabled = isLaserEnabled; } + + @Override + public void setBorderCornerRounded(boolean isBorderCornersRounded) { + if (isBorderCornersRounded) { + mBorderPaint.setStrokeJoin(Paint.Join.ROUND); + } else { + mBorderPaint.setStrokeJoin(Paint.Join.BEVEL); + } + } + + @Override + public void setBorderAlpha(float alpha) { + int colorAlpha = (int) (255 * alpha); + mBordersAlpha = alpha; + mBorderPaint.setAlpha(colorAlpha); + } + + @Override + public void setBorderCornerRadius(int borderCornersRadius) { + mBorderPaint.setPathEffect(new CornerPathEffect(borderCornersRadius)); + } + + @Override + public void setViewFinderOffset(int offset) { + mViewFinderOffset = offset; + } + + // TODO: Need a better way to configure this. Revisit when working on 2.0 + @Override + public void setSquareViewFinder(boolean set) { + mSquareViewFinder = set; + } + + public void setupViewFinder() { + updateFramingRect(); + invalidate(); + } + + public Rect getFramingRect() { + return mFramingRect; + } + + @Override + public void onDraw(Canvas canvas) { + if(getFramingRect() == null) { + return; + } + + drawViewFinderMask(canvas); + drawViewFinderBorder(canvas); + + if (mIsLaserEnabled) { + drawLaser(canvas); + } + } + + public void drawViewFinderMask(Canvas canvas) { + int width = canvas.getWidth(); + int height = canvas.getHeight(); + Rect framingRect = getFramingRect(); + + canvas.drawRect(0, 0, width, framingRect.top, mFinderMaskPaint); + canvas.drawRect(0, framingRect.top, framingRect.left, framingRect.bottom + 1, mFinderMaskPaint); + canvas.drawRect(framingRect.right + 1, framingRect.top, width, framingRect.bottom + 1, mFinderMaskPaint); + canvas.drawRect(0, framingRect.bottom + 1, width, height, mFinderMaskPaint); + } + + public void drawViewFinderBorder(Canvas canvas) { + Rect framingRect = getFramingRect(); + + // Top-left corner + Path path = new Path(); + path.moveTo(framingRect.left, framingRect.top + mBorderLineLength); + path.lineTo(framingRect.left, framingRect.top); + path.lineTo(framingRect.left + mBorderLineLength, framingRect.top); + canvas.drawPath(path, mBorderPaint); + + // Top-right corner + path.moveTo(framingRect.right, framingRect.top + mBorderLineLength); + path.lineTo(framingRect.right, framingRect.top); + path.lineTo(framingRect.right - mBorderLineLength, framingRect.top); + canvas.drawPath(path, mBorderPaint); + + // Bottom-right corner + path.moveTo(framingRect.right, framingRect.bottom - mBorderLineLength); + path.lineTo(framingRect.right, framingRect.bottom); + path.lineTo(framingRect.right - mBorderLineLength, framingRect.bottom); + canvas.drawPath(path, mBorderPaint); + + // Bottom-left corner + path.moveTo(framingRect.left, framingRect.bottom - mBorderLineLength); + path.lineTo(framingRect.left, framingRect.bottom); + path.lineTo(framingRect.left + mBorderLineLength, framingRect.bottom); + canvas.drawPath(path, mBorderPaint); + } + + public void drawLaser(Canvas canvas) { + Rect framingRect = getFramingRect(); + + // Draw a red "laser scanner" line through the middle to show decoding is active + mLaserPaint.setAlpha(SCANNER_ALPHA[scannerAlpha]); + scannerAlpha = (scannerAlpha + 1) % SCANNER_ALPHA.length; + int middle = framingRect.height() / 2 + framingRect.top; + canvas.drawRect(framingRect.left + 2, middle - 1, framingRect.right - 1, middle + 2, mLaserPaint); + + postInvalidateDelayed(ANIMATION_DELAY, + framingRect.left - POINT_SIZE, + framingRect.top - POINT_SIZE, + framingRect.right + POINT_SIZE, + framingRect.bottom + POINT_SIZE); + } + + @Override + protected void onSizeChanged(int xNew, int yNew, int xOld, int yOld) { + updateFramingRect(); + } + + public synchronized void updateFramingRect() { + Point viewResolution = new Point(getWidth(), getHeight()); + int width; + int height; + int orientation = DisplayUtils.getScreenOrientation(getContext()); + + if(mSquareViewFinder) { + if(orientation != Configuration.ORIENTATION_PORTRAIT) { + height = (int) (getHeight() * DEFAULT_SQUARE_DIMENSION_RATIO); + width = height; + } else { + width = (int) (getWidth() * DEFAULT_SQUARE_DIMENSION_RATIO); + height = width; + } + } else { + if(orientation != Configuration.ORIENTATION_PORTRAIT) { + height = (int) (getHeight() * LANDSCAPE_HEIGHT_RATIO); + width = (int) (LANDSCAPE_WIDTH_HEIGHT_RATIO * height); + } else { + width = (int) (getWidth() * PORTRAIT_WIDTH_RATIO); + height = (int) (PORTRAIT_WIDTH_HEIGHT_RATIO * width); + } + } + + if(width > getWidth()) { + width = getWidth() - MIN_DIMENSION_DIFF; + } + + if(height > getHeight()) { + height = getHeight() - MIN_DIMENSION_DIFF; + } + + int leftOffset = (viewResolution.x - width) / 2; + int topOffset = (viewResolution.y - height) / 2; + mFramingRect = new Rect(leftOffset + mViewFinderOffset, topOffset + mViewFinderOffset, leftOffset + width - mViewFinderOffset, topOffset + height - mViewFinderOffset); + } +} + diff --git a/library/external/barcodescanner/core/src/main/res/values-hdpi/integers.xml b/library/external/barcodescanner/core/src/main/res/values-hdpi/integers.xml new file mode 100644 index 0000000000..d5979caaf8 --- /dev/null +++ b/library/external/barcodescanner/core/src/main/res/values-hdpi/integers.xml @@ -0,0 +1,5 @@ + + + 4 + 60 + \ No newline at end of file diff --git a/library/external/barcodescanner/core/src/main/res/values-xhdpi/integers.xml b/library/external/barcodescanner/core/src/main/res/values-xhdpi/integers.xml new file mode 100644 index 0000000000..7f57b00a3a --- /dev/null +++ b/library/external/barcodescanner/core/src/main/res/values-xhdpi/integers.xml @@ -0,0 +1,5 @@ + + + 5 + 80 + \ No newline at end of file diff --git a/library/external/barcodescanner/core/src/main/res/values-xxhdpi/integers.xml b/library/external/barcodescanner/core/src/main/res/values-xxhdpi/integers.xml new file mode 100644 index 0000000000..d69e2a8b0f --- /dev/null +++ b/library/external/barcodescanner/core/src/main/res/values-xxhdpi/integers.xml @@ -0,0 +1,5 @@ + + + 6 + 100 + \ No newline at end of file diff --git a/library/external/barcodescanner/core/src/main/res/values/attrs.xml b/library/external/barcodescanner/core/src/main/res/values/attrs.xml new file mode 100644 index 0000000000..8e7485ebb3 --- /dev/null +++ b/library/external/barcodescanner/core/src/main/res/values/attrs.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/library/external/barcodescanner/core/src/main/res/values/colors.xml b/library/external/barcodescanner/core/src/main/res/values/colors.xml new file mode 100644 index 0000000000..58c019a6a9 --- /dev/null +++ b/library/external/barcodescanner/core/src/main/res/values/colors.xml @@ -0,0 +1,6 @@ + + + #60000000 + #ffcc0000 + #ffafed44 + \ No newline at end of file diff --git a/library/external/barcodescanner/core/src/main/res/values/integers.xml b/library/external/barcodescanner/core/src/main/res/values/integers.xml new file mode 100644 index 0000000000..690b6cd3c9 --- /dev/null +++ b/library/external/barcodescanner/core/src/main/res/values/integers.xml @@ -0,0 +1,5 @@ + + + 4 + 60 + diff --git a/library/external/barcodescanner/zxing/build.gradle b/library/external/barcodescanner/zxing/build.gradle new file mode 100644 index 0000000000..e84d0f5bb9 --- /dev/null +++ b/library/external/barcodescanner/zxing/build.gradle @@ -0,0 +1,29 @@ +apply plugin: 'com.android.library' + +android { + namespace "me.dm7.barcodescanner.zxing" + + compileSdk versions.compileSdk + + defaultConfig { + minSdk versions.minSdk + targetSdk versions.targetSdk + } + + compileOptions { + sourceCompatibility versions.sourceCompat + targetCompatibility versions.targetCompat + } +} + +dependencies { + api project(":library:external:barcodescanner:core") + // Stick to 3.3.3 because of https://github.com/zxing/zxing/issues/1170 + api 'com.google.zxing:core:3.3.3' +} + +afterEvaluate { + tasks.findAll { it.name.startsWith("lint") }.each { + it.enabled = false + } +} diff --git a/library/external/barcodescanner/zxing/src/main/java/me/dm7/barcodescanner/zxing/ZXingScannerView.java b/library/external/barcodescanner/zxing/src/main/java/me/dm7/barcodescanner/zxing/ZXingScannerView.java new file mode 100644 index 0000000000..d1717ba5be --- /dev/null +++ b/library/external/barcodescanner/zxing/src/main/java/me/dm7/barcodescanner/zxing/ZXingScannerView.java @@ -0,0 +1,198 @@ +package me.dm7.barcodescanner.zxing; + +import android.content.Context; +import android.content.res.Configuration; +import android.graphics.Rect; +import android.hardware.Camera; +import android.os.Handler; +import android.os.Looper; +import android.util.AttributeSet; +import android.util.Log; + +import com.google.zxing.BarcodeFormat; +import com.google.zxing.BinaryBitmap; +import com.google.zxing.DecodeHintType; +import com.google.zxing.LuminanceSource; +import com.google.zxing.MultiFormatReader; +import com.google.zxing.NotFoundException; +import com.google.zxing.PlanarYUVLuminanceSource; +import com.google.zxing.ReaderException; +import com.google.zxing.Result; +import com.google.zxing.common.HybridBinarizer; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; + +import me.dm7.barcodescanner.core.BarcodeScannerView; +import me.dm7.barcodescanner.core.DisplayUtils; + +public class ZXingScannerView extends BarcodeScannerView { + private static final String TAG = "ZXingScannerView"; + + public interface ResultHandler { + void handleResult(Result rawResult); + } + + private MultiFormatReader mMultiFormatReader; + public static final List ALL_FORMATS = new ArrayList<>(); + private List mFormats; + private ResultHandler mResultHandler; + + static { + ALL_FORMATS.add(BarcodeFormat.AZTEC); + ALL_FORMATS.add(BarcodeFormat.CODABAR); + ALL_FORMATS.add(BarcodeFormat.CODE_39); + ALL_FORMATS.add(BarcodeFormat.CODE_93); + ALL_FORMATS.add(BarcodeFormat.CODE_128); + ALL_FORMATS.add(BarcodeFormat.DATA_MATRIX); + ALL_FORMATS.add(BarcodeFormat.EAN_8); + ALL_FORMATS.add(BarcodeFormat.EAN_13); + ALL_FORMATS.add(BarcodeFormat.ITF); + ALL_FORMATS.add(BarcodeFormat.MAXICODE); + ALL_FORMATS.add(BarcodeFormat.PDF_417); + ALL_FORMATS.add(BarcodeFormat.QR_CODE); + ALL_FORMATS.add(BarcodeFormat.RSS_14); + ALL_FORMATS.add(BarcodeFormat.RSS_EXPANDED); + ALL_FORMATS.add(BarcodeFormat.UPC_A); + ALL_FORMATS.add(BarcodeFormat.UPC_E); + ALL_FORMATS.add(BarcodeFormat.UPC_EAN_EXTENSION); + } + + public ZXingScannerView(Context context) { + super(context); + initMultiFormatReader(); + } + + public ZXingScannerView(Context context, AttributeSet attributeSet) { + super(context, attributeSet); + initMultiFormatReader(); + } + + public void setFormats(List formats) { + mFormats = formats; + initMultiFormatReader(); + } + + public void setResultHandler(ResultHandler resultHandler) { + mResultHandler = resultHandler; + } + + public Collection getFormats() { + if(mFormats == null) { + return ALL_FORMATS; + } + return mFormats; + } + + private void initMultiFormatReader() { + Map hints = new EnumMap<>(DecodeHintType.class); + hints.put(DecodeHintType.POSSIBLE_FORMATS, getFormats()); + mMultiFormatReader = new MultiFormatReader(); + mMultiFormatReader.setHints(hints); + } + + @Override + public void onPreviewFrame(byte[] data, Camera camera) { + if(mResultHandler == null) { + return; + } + + try { + Camera.Parameters parameters = camera.getParameters(); + Camera.Size size = parameters.getPreviewSize(); + int width = size.width; + int height = size.height; + + if (DisplayUtils.getScreenOrientation(getContext()) == Configuration.ORIENTATION_PORTRAIT) { + int rotationCount = getRotationCount(); + if (rotationCount == 1 || rotationCount == 3) { + int tmp = width; + width = height; + height = tmp; + } + data = getRotatedData(data, camera); + } + + Result rawResult = null; + PlanarYUVLuminanceSource source = buildLuminanceSource(data, width, height); + + if (source != null) { + BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(source)); + try { + rawResult = mMultiFormatReader.decodeWithState(bitmap); + } catch (ReaderException re) { + // continue + } catch (NullPointerException npe) { + // This is terrible + } catch (ArrayIndexOutOfBoundsException aoe) { + + } finally { + mMultiFormatReader.reset(); + } + + if (rawResult == null) { + LuminanceSource invertedSource = source.invert(); + bitmap = new BinaryBitmap(new HybridBinarizer(invertedSource)); + try { + rawResult = mMultiFormatReader.decodeWithState(bitmap); + } catch (NotFoundException e) { + // continue + } finally { + mMultiFormatReader.reset(); + } + } + } + + final Result finalRawResult = rawResult; + + if (finalRawResult != null) { + Handler handler = new Handler(Looper.getMainLooper()); + handler.post(new Runnable() { + @Override + public void run() { + // Stopping the preview can take a little long. + // So we want to set result handler to null to discard subsequent calls to + // onPreviewFrame. + ResultHandler tmpResultHandler = mResultHandler; + mResultHandler = null; + + stopCameraPreview(); + if (tmpResultHandler != null) { + tmpResultHandler.handleResult(finalRawResult); + } + } + }); + } else { + camera.setOneShotPreviewCallback(this); + } + } catch(RuntimeException e) { + // TODO: Terrible hack. It is possible that this method is invoked after camera is released. + Log.e(TAG, e.toString(), e); + } + } + + public void resumeCameraPreview(ResultHandler resultHandler) { + mResultHandler = resultHandler; + super.resumeCameraPreview(); + } + + public PlanarYUVLuminanceSource buildLuminanceSource(byte[] data, int width, int height) { + Rect rect = getFramingRectInPreview(width, height); + if (rect == null) { + return null; + } + // Go ahead and assume it's YUV rather than die. + PlanarYUVLuminanceSource source = null; + + try { + source = new PlanarYUVLuminanceSource(data, width, height, rect.left, rect.top, + rect.width(), rect.height(), false); + } catch(Exception e) { + } + + return source; + } +} diff --git a/library/external/jsonviewer/build.gradle b/library/external/jsonviewer/build.gradle index 7b3b62c082..c501f583d0 100644 --- a/library/external/jsonviewer/build.gradle +++ b/library/external/jsonviewer/build.gradle @@ -58,9 +58,7 @@ dependencies { implementation libs.airbnb.mavericks // Span utils - implementation('me.gujun.android:span:1.7') { - exclude group: 'com.android.support', module: 'support-annotations' - } + implementation project(":library:external:span") implementation libs.jetbrains.coroutinesCore implementation libs.jetbrains.coroutinesAndroid diff --git a/library/external/realmfieldnameshelper/build.gradle b/library/external/realmfieldnameshelper/build.gradle new file mode 100644 index 0000000000..e051550210 --- /dev/null +++ b/library/external/realmfieldnameshelper/build.gradle @@ -0,0 +1,23 @@ +apply plugin: 'kotlin' +apply plugin: 'java' + +sourceCompatibility = versions.sourceCompat +targetCompatibility = versions.sourceCompat + +dependencies { + implementation 'com.squareup:javapoet:1.13.0' +} + +task javadocJar(type: Jar, dependsOn: 'javadoc') { + from javadoc.destinationDir + classifier = 'javadoc' +} +task sourcesJar(type: Jar, dependsOn: 'classes') { + from sourceSets.main.allSource + classifier = 'sources' +} + +sourceSets { + main.java.srcDirs += 'src/main/kotlin' +} + diff --git a/library/external/realmfieldnameshelper/src/main/kotlin/dk/ilios/realmfieldnames/ClassData.kt b/library/external/realmfieldnameshelper/src/main/kotlin/dk/ilios/realmfieldnames/ClassData.kt new file mode 100644 index 0000000000..d683a2adef --- /dev/null +++ b/library/external/realmfieldnameshelper/src/main/kotlin/dk/ilios/realmfieldnames/ClassData.kt @@ -0,0 +1,24 @@ +package dk.ilios.realmfieldnames + +import java.util.TreeMap + +/** + * Class responsible for keeping track of the metadata for each Realm model class. + */ +class ClassData(val packageName: String?, val simpleClassName: String, val libraryClass: Boolean = false) { + + val fields = TreeMap() // + + fun addField(field: String, linkedType: String?) { + fields.put(field, linkedType) + } + + val qualifiedClassName: String + get() { + if (packageName != null && !packageName.isEmpty()) { + return packageName + "." + simpleClassName + } else { + return simpleClassName + } + } +} diff --git a/library/external/realmfieldnameshelper/src/main/kotlin/dk/ilios/realmfieldnames/FieldNameFormatter.kt b/library/external/realmfieldnameshelper/src/main/kotlin/dk/ilios/realmfieldnames/FieldNameFormatter.kt new file mode 100644 index 0000000000..95f0024721 --- /dev/null +++ b/library/external/realmfieldnameshelper/src/main/kotlin/dk/ilios/realmfieldnames/FieldNameFormatter.kt @@ -0,0 +1,79 @@ +package dk.ilios.realmfieldnames + +import java.util.Locale + +/** + * Class for encapsulating the rules for converting between the field name in the Realm model class + * and the matching name in the "<class>Fields" class. + */ +class FieldNameFormatter { + + @JvmOverloads + fun format(fieldName: String?, locale: Locale = Locale.US): String { + if (fieldName == null || fieldName == "") { + return "" + } + + // Normalize word separator chars + val normalizedFieldName: String = fieldName.replace('-', '_') + + // Iterate field name using the following rules + // lowerCase m followed by upperCase anything is considered hungarian notation + // lowercase char followed by uppercase char is considered camel case + // Two uppercase chars following each other is considered non-standard camelcase + // _ and - are treated as word separators + val result = StringBuilder(normalizedFieldName.length) + + if (normalizedFieldName.codePointCount(0, normalizedFieldName.length) == 1) { + result.append(normalizedFieldName) + } else { + var previousCodepoint: Int? + var currentCodepoint: Int? = null + val length = normalizedFieldName.length + var offset = 0 + while (offset < length) { + previousCodepoint = currentCodepoint + currentCodepoint = normalizedFieldName.codePointAt(offset) + + if (previousCodepoint != null) { + if (Character.isUpperCase(currentCodepoint) && + !Character.isUpperCase(previousCodepoint) && + previousCodepoint === 'm'.code as Int? && + result.length == 1 + ) { + // Hungarian notation starting with: mX + result.delete(0, 1) + result.appendCodePoint(currentCodepoint) + } else if (Character.isUpperCase(currentCodepoint) && Character.isUpperCase(previousCodepoint)) { + // InvalidCamelCase: XXYx (should have been xxYx) + if (offset + Character.charCount(currentCodepoint) < normalizedFieldName.length) { + val nextCodePoint = normalizedFieldName.codePointAt(offset + Character.charCount(currentCodepoint)) + if (Character.isLowerCase(nextCodePoint)) { + result.append("_") + } + } + result.appendCodePoint(currentCodepoint) + } else if (currentCodepoint === '-'.code as Int? || currentCodepoint === '_'.code as Int?) { + // Word-separator: x-x or x_x + result.append("_") + } else if (Character.isUpperCase(currentCodepoint) && !Character.isUpperCase(previousCodepoint) && Character.isLetterOrDigit( + previousCodepoint + )) { + // camelCase: xX + result.append("_") + result.appendCodePoint(currentCodepoint) + } else { + // Unknown type + result.appendCodePoint(currentCodepoint) + } + } else { + // Only triggered for first code point + result.appendCodePoint(currentCodepoint) + } + offset += Character.charCount(currentCodepoint) + } + } + + return result.toString().uppercase(locale) + } +} diff --git a/library/external/realmfieldnameshelper/src/main/kotlin/dk/ilios/realmfieldnames/FileGenerator.kt b/library/external/realmfieldnameshelper/src/main/kotlin/dk/ilios/realmfieldnames/FileGenerator.kt new file mode 100644 index 0000000000..2ddba1ccbd --- /dev/null +++ b/library/external/realmfieldnameshelper/src/main/kotlin/dk/ilios/realmfieldnames/FileGenerator.kt @@ -0,0 +1,77 @@ +package dk.ilios.realmfieldnames + +import com.squareup.javapoet.FieldSpec +import com.squareup.javapoet.JavaFile +import com.squareup.javapoet.TypeSpec +import java.io.IOException +import javax.annotation.processing.Filer +import javax.lang.model.element.Modifier + +/** + * Class responsible for creating the final output files. + */ +class FileGenerator(private val filer: Filer) { + private val formatter: FieldNameFormatter + + init { + this.formatter = FieldNameFormatter() + } + + /** + * Generates all the "<class>Fields" fields with field name references. + * @param fileData Files to create. + * * + * @return `true` if the files where generated, `false` if not. + */ + fun generate(fileData: Set): Boolean { + return fileData + .filter { !it.libraryClass } + .all { generateFile(it, fileData) } + } + + private fun generateFile(classData: ClassData, classPool: Set): Boolean { + val fileBuilder = TypeSpec.classBuilder(classData.simpleClassName + "Fields") + .addModifiers(Modifier.PUBLIC, Modifier.FINAL) + .addJavadoc("This class enumerate all queryable fields in {@link \$L.\$L}\n", + classData.packageName, classData.simpleClassName) + + // Add a static field reference to each queryable field in the Realm model class + classData.fields.forEach { fieldName, value -> + if (value != null) { + // Add linked field names (only up to depth 1) + for (data in classPool) { + if (data.qualifiedClassName == value) { + val linkedTypeSpec = TypeSpec.classBuilder(formatter.format(fieldName)) + .addModifiers(Modifier.PUBLIC, Modifier.FINAL, Modifier.STATIC) + val linkedClassFields = data.fields + addField(linkedTypeSpec, "$", fieldName) + for (linkedFieldName in linkedClassFields.keys) { + addField(linkedTypeSpec, linkedFieldName, fieldName + "." + linkedFieldName) + } + fileBuilder.addType(linkedTypeSpec.build()) + } + } + } else { + // Add normal field name + addField(fileBuilder, fieldName, fieldName) + } + } + + val javaFile = JavaFile.builder(classData.packageName, fileBuilder.build()).build() + try { + javaFile.writeTo(filer) + return true + } catch (e: IOException) { + // e.printStackTrace() + return false + } + } + + private fun addField(fileBuilder: TypeSpec.Builder, fieldName: String, fieldNameValue: String) { + val field = FieldSpec.builder(String::class.java, formatter.format(fieldName)) + .addModifiers(Modifier.PUBLIC, Modifier.STATIC, Modifier.FINAL) + .initializer("\$S", fieldNameValue) + .build() + fileBuilder.addField(field) + } +} diff --git a/library/external/realmfieldnameshelper/src/main/kotlin/dk/ilios/realmfieldnames/RealmFieldNamesProcessor.kt b/library/external/realmfieldnameshelper/src/main/kotlin/dk/ilios/realmfieldnames/RealmFieldNamesProcessor.kt new file mode 100644 index 0000000000..29d044c46c --- /dev/null +++ b/library/external/realmfieldnameshelper/src/main/kotlin/dk/ilios/realmfieldnames/RealmFieldNamesProcessor.kt @@ -0,0 +1,197 @@ +package dk.ilios.realmfieldnames + +import javax.annotation.processing.AbstractProcessor +import javax.annotation.processing.Messager +import javax.annotation.processing.ProcessingEnvironment +import javax.annotation.processing.RoundEnvironment +import javax.annotation.processing.SupportedAnnotationTypes +import javax.lang.model.SourceVersion +import javax.lang.model.element.Element +import javax.lang.model.element.ElementKind +import javax.lang.model.element.Modifier +import javax.lang.model.element.PackageElement +import javax.lang.model.element.TypeElement +import javax.lang.model.element.VariableElement +import javax.lang.model.type.DeclaredType +import javax.lang.model.type.TypeMirror +import javax.lang.model.util.Elements +import javax.lang.model.util.Types +import javax.tools.Diagnostic + +/** + * The Realm Field Names Generator is a processor that looks at all available Realm model classes + * and create an companion class with easy, type-safe access to all field names. + */ + +@SupportedAnnotationTypes("io.realm.annotations.RealmClass") +class RealmFieldNamesProcessor : AbstractProcessor() { + + private val classes = HashSet() + private lateinit var typeUtils: Types + private lateinit var messager: Messager + private lateinit var elementUtils: Elements + private var ignoreAnnotation: TypeMirror? = null + private var realmClassAnnotation: TypeElement? = null + private var realmModelInterface: TypeMirror? = null + private var realmListClass: DeclaredType? = null + private var realmResultsClass: DeclaredType? = null + private var fileGenerator: FileGenerator? = null + private var done = false + + @Synchronized + override fun init(processingEnv: ProcessingEnvironment) { + super.init(processingEnv) + typeUtils = processingEnv.typeUtils!! + messager = processingEnv.messager!! + elementUtils = processingEnv.elementUtils!! + + // If the Realm class isn't found something is wrong the project setup. + // Most likely Realm isn't on the class path, so just disable the + // annotation processor + val isRealmAvailable = elementUtils.getTypeElement("io.realm.Realm") != null + if (!isRealmAvailable) { + done = true + } else { + ignoreAnnotation = elementUtils.getTypeElement("io.realm.annotations.Ignore")?.asType() + realmClassAnnotation = elementUtils.getTypeElement("io.realm.annotations.RealmClass") + realmModelInterface = elementUtils.getTypeElement("io.realm.RealmModel")?.asType() + realmListClass = typeUtils.getDeclaredType( + elementUtils.getTypeElement("io.realm.RealmList"), + typeUtils.getWildcardType(null, null) + ) + realmResultsClass = typeUtils.getDeclaredType( + elementUtils.getTypeElement("io.realm.RealmResults"), + typeUtils.getWildcardType(null, null) + ) + fileGenerator = FileGenerator(processingEnv.filer) + } + } + + override fun getSupportedSourceVersion(): SourceVersion { + return SourceVersion.latestSupported() + } + + override fun process(annotations: Set, roundEnv: RoundEnvironment): Boolean { + if (done) { + return CONSUME_ANNOTATIONS + } + + // Create all proxy classes + roundEnv.getElementsAnnotatedWith(realmClassAnnotation).forEach { classElement -> + if (typeUtils.isAssignable(classElement.asType(), realmModelInterface)) { + val classData = processClass(classElement as TypeElement) + classes.add(classData) + } + } + + // If a model class references a library class, the library class will not be part of this + // annotation processor round. For all those references we need to pull field information + // from the classpath instead. + val libraryClasses = HashMap() + classes.forEach { + it.fields.forEach { _, value -> + // Analyze the library class file the first time it is encountered. + if (value != null) { + if (classes.all { it.qualifiedClassName != value } && !libraryClasses.containsKey(value)) { + libraryClasses.put(value, processLibraryClass(value)) + } + } + } + } + classes.addAll(libraryClasses.values) + + done = fileGenerator!!.generate(classes) + return CONSUME_ANNOTATIONS + } + + private fun processClass(classElement: TypeElement): ClassData { + val packageName = getPackageName(classElement) + val className = classElement.simpleName.toString() + val data = ClassData(packageName, className) + + // Find all appropriate fields + classElement.enclosedElements.forEach { + val elementKind = it.kind + if (elementKind == ElementKind.FIELD) { + val variableElement = it as VariableElement + + val modifiers = variableElement.modifiers + if (modifiers.contains(Modifier.STATIC)) { + return@forEach // completely ignore any static fields + } + + // Don't add any fields marked with @Ignore + val ignoreField = variableElement.annotationMirrors + .map { it.annotationType.toString() } + .contains("io.realm.annotations.Ignore") + + if (!ignoreField) { + data.addField(it.getSimpleName().toString(), getLinkedFieldType(it)) + } + } + } + + return data + } + + private fun processLibraryClass(qualifiedClassName: String): ClassData { + val libraryClass = Class.forName(qualifiedClassName) // Library classes should be on the classpath + val packageName = libraryClass.`package`.name + val className = libraryClass.simpleName + val data = ClassData(packageName, className, libraryClass = true) + + libraryClass.declaredFields.forEach { field -> + if (java.lang.reflect.Modifier.isStatic(field.modifiers)) { + return@forEach // completely ignore any static fields + } + + // Add field if it is not being ignored. + if (field.annotations.all { it.toString() != "io.realm.annotations.Ignore" }) { + data.addField(field.name, field.type.name) + } + } + + return data + } + + /** + * Returns the qualified name of the linked Realm class field or `null` if it is not a linked + * class. + */ + private fun getLinkedFieldType(field: Element): String? { + if (typeUtils.isAssignable(field.asType(), realmModelInterface)) { + // Object link + val typeElement = elementUtils.getTypeElement(field.asType().toString()) + return typeElement.qualifiedName.toString() + } else if (typeUtils.isAssignable(field.asType(), realmListClass) || typeUtils.isAssignable(field.asType(), realmResultsClass)) { + // List link or LinkingObjects + val fieldType = field.asType() + val typeArguments = (fieldType as DeclaredType).typeArguments + if (typeArguments.size == 0) { + return null + } + return typeArguments[0].toString() + } else { + return null + } + } + + private fun getPackageName(classElement: TypeElement): String? { + val enclosingElement = classElement.enclosingElement + + if (enclosingElement.kind != ElementKind.PACKAGE) { + messager.printMessage( + Diagnostic.Kind.ERROR, + "Could not determine the package name. Enclosing element was: " + enclosingElement.kind + ) + return null + } + + val packageElement = enclosingElement as PackageElement + return packageElement.qualifiedName.toString() + } + + companion object { + private const val CONSUME_ANNOTATIONS = false + } +} diff --git a/library/external/realmfieldnameshelper/src/main/resources/META-INF/gradle/incremental.annotation.processors b/library/external/realmfieldnameshelper/src/main/resources/META-INF/gradle/incremental.annotation.processors new file mode 100644 index 0000000000..57897c8297 --- /dev/null +++ b/library/external/realmfieldnameshelper/src/main/resources/META-INF/gradle/incremental.annotation.processors @@ -0,0 +1 @@ +dk.ilios.realmfieldnames.RealmFieldNamesProcessor,aggregating \ No newline at end of file diff --git a/library/external/realmfieldnameshelper/src/main/resources/META-INF/services/javax.annotation.processing.Processor b/library/external/realmfieldnameshelper/src/main/resources/META-INF/services/javax.annotation.processing.Processor new file mode 100644 index 0000000000..58fadd699c --- /dev/null +++ b/library/external/realmfieldnameshelper/src/main/resources/META-INF/services/javax.annotation.processing.Processor @@ -0,0 +1 @@ +dk.ilios.realmfieldnames.RealmFieldNamesProcessor \ No newline at end of file diff --git a/library/external/span/build.gradle b/library/external/span/build.gradle new file mode 100644 index 0000000000..05adbacb4d --- /dev/null +++ b/library/external/span/build.gradle @@ -0,0 +1,20 @@ +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + namespace "me.gujun.android.span" + compileSdk versions.compileSdk + + defaultConfig { + minSdk versions.minSdk + targetSdk versions.targetSdk + } + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } +} + +dependencies { + implementation 'com.android.support:support-annotations:28.0.0' +} diff --git a/library/external/span/src/main/kotlin/me/gujun/android/span/Span.kt b/library/external/span/src/main/kotlin/me/gujun/android/span/Span.kt new file mode 100644 index 0000000000..5ca63b7e01 --- /dev/null +++ b/library/external/span/src/main/kotlin/me/gujun/android/span/Span.kt @@ -0,0 +1,316 @@ +package me.gujun.android.span + +import android.graphics.Typeface +import android.graphics.drawable.Drawable +import android.text.Layout +import android.text.Spannable +import android.text.SpannableStringBuilder +import android.text.TextUtils +import android.text.style.AbsoluteSizeSpan +import android.text.style.AlignmentSpan +import android.text.style.BackgroundColorSpan +import android.text.style.ForegroundColorSpan +import android.text.style.ImageSpan +import android.text.style.QuoteSpan +import android.text.style.StyleSpan +import android.text.style.SubscriptSpan +import android.text.style.SuperscriptSpan +import android.text.style.TypefaceSpan +import android.text.style.URLSpan +import android.view.View +import androidx.annotation.ColorInt +import androidx.annotation.Dimension +import me.gujun.android.span.style.CustomTypefaceSpan +import me.gujun.android.span.style.LineSpacingSpan +import me.gujun.android.span.style.SimpleClickableSpan +import me.gujun.android.span.style.TextDecorationLineSpan +import me.gujun.android.span.style.VerticalPaddingSpan + +class Span(val parent: Span? = null) : SpannableStringBuilder() { + + companion object { + val EMPTY_STYLE = Span() + + var globalStyle: Span = EMPTY_STYLE + } + + var text: CharSequence = "" + + @ColorInt var textColor: Int? = parent?.textColor + + @ColorInt var backgroundColor: Int? = parent?.backgroundColor + + @Dimension(unit = Dimension.PX) var textSize: Int? = parent?.textSize + + var fontFamily: String? = parent?.fontFamily + + var typeface: Typeface? = parent?.typeface + + var textStyle: String? = parent?.textStyle + + var alignment: String? = parent?.alignment + + var textDecorationLine: String? = parent?.textDecorationLine + + @Dimension(unit = Dimension.PX) var lineSpacing: Int? = null + + @Dimension(unit = Dimension.PX) var paddingTop: Int? = null + + @Dimension(unit = Dimension.PX) var paddingBottom: Int? = null + + @Dimension(unit = Dimension.PX) var verticalPadding: Int? = null + + var onClick: (() -> Unit)? = null + + var spans: ArrayList = ArrayList() + + var style: Span = EMPTY_STYLE + + private fun buildCharacterStyle(builder: ArrayList) { + if (textColor != null) { + builder.add(ForegroundColorSpan(textColor!!)) + } + + if (backgroundColor != null) { + builder.add(BackgroundColorSpan(backgroundColor!!)) + } + + if (textSize != null) { + builder.add(AbsoluteSizeSpan(textSize!!)) + } + + if (!TextUtils.isEmpty(fontFamily)) { + builder.add(TypefaceSpan(fontFamily)) + } + + if (typeface != null) { + builder.add(CustomTypefaceSpan(typeface!!)) + } + + if (!TextUtils.isEmpty(textStyle)) { + builder.add(StyleSpan(when (textStyle) { + "normal" -> Typeface.NORMAL + "bold" -> Typeface.BOLD + "italic" -> Typeface.ITALIC + "bold_italic" -> Typeface.BOLD_ITALIC + else -> throw RuntimeException("Unknown text style") + })) + } + + if (!TextUtils.isEmpty(textDecorationLine)) { + builder.add(TextDecorationLineSpan(textDecorationLine!!)) + } + + if (onClick != null) { + builder.add(object : SimpleClickableSpan() { + override fun onClick(widget: View) { + onClick?.invoke() + } + }) + } + } + + private fun buildParagraphStyle(builder: ArrayList) { + if (!TextUtils.isEmpty(alignment)) { + builder.add(AlignmentSpan.Standard(when (alignment) { + "normal" -> Layout.Alignment.ALIGN_NORMAL + "opposite" -> Layout.Alignment.ALIGN_OPPOSITE + "center" -> Layout.Alignment.ALIGN_CENTER + else -> throw RuntimeException("Unknown text alignment") + })) + } + + if (lineSpacing != null) { + builder.add(LineSpacingSpan(lineSpacing!!)) + } + + paddingTop = when { + paddingTop != null -> paddingTop + verticalPadding != null -> verticalPadding + else -> 0 + } + paddingBottom = when { + paddingBottom != null -> paddingBottom + verticalPadding != null -> verticalPadding + else -> 0 + } + if (paddingTop != 0 || paddingBottom != 0) { + builder.add(VerticalPaddingSpan(paddingTop!!, paddingBottom!!)) + } + } + + private fun prebuild() { + override(style) + } + + fun build(): Span { + prebuild() + val builder = ArrayList() + if (!TextUtils.isEmpty(text)) { + var p = this.parent + while (p != null) { + if (!TextUtils.isEmpty(p.text)) { + throw RuntimeException("Can't nest \"$text\" in spans") + } + p = p.parent + } + append(text) + buildCharacterStyle(builder) + buildParagraphStyle(builder) + } else { + buildParagraphStyle(builder) + } + + builder.addAll(spans) + builder.forEach { + setSpan(it, 0, length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + } + return this + } + + fun override(style: Span) { + if (textColor == null) { + textColor = style.textColor + } + if (backgroundColor == null) { + backgroundColor = style.backgroundColor + } + if (textSize == null) { + textSize = style.textSize + } + if (fontFamily == null) { + fontFamily = style.fontFamily + } + if (typeface == null) { + typeface = style.typeface + } + if (textStyle == null) { + textStyle = style.textStyle + } + if (alignment == null) { + alignment = style.alignment + } + if (textDecorationLine == null) { + textDecorationLine = style.textDecorationLine + } + if (lineSpacing == null) { + lineSpacing = style.lineSpacing + } + if (paddingTop == null) { + paddingTop = style.paddingTop + } + if (paddingBottom == null) { + paddingBottom = style.paddingBottom + } + if (verticalPadding == null) { + verticalPadding = style.verticalPadding + } + if (onClick == null) { + onClick = style.onClick + } + spans.addAll(style.spans) + } + + operator fun CharSequence.unaryPlus(): CharSequence { + return append(Span(parent = this@Span).apply { + text = this@unaryPlus + build() + }) + } + + operator fun Span.plus(other: CharSequence): CharSequence { + return append(Span(parent = this).apply { + text = other + build() + }) + } +} + +fun span(init: Span.() -> Unit): Span = Span().apply { + override(Span.globalStyle) + init() + build() +} + +fun span(text: CharSequence, init: Span.() -> Unit): Span = Span().apply { + override(Span.globalStyle) + this.text = text + init() + build() +} + +fun style(init: Span.() -> Unit): Span = Span().apply { + init() +} + +fun Span.span(init: Span.() -> Unit = {}): Span = apply { + append(Span(parent = this).apply { + init() + build() + }) +} + +fun Span.span(text: CharSequence, init: Span.() -> Unit = {}): Span = apply { + append(Span(parent = this).apply { + this.text = text + init() + build() + }) +} + +fun Span.link(url: String, text: CharSequence = "", + init: Span.() -> Unit = {}): Span = apply { + append(Span(parent = this).apply { + this.text = text + this.spans.add(URLSpan(url)) + init() + build() + }) +} + +fun Span.quote(@ColorInt color: Int, text: CharSequence = "", + init: Span.() -> Unit = {}): Span = apply { + append(Span(parent = this).apply { + this.text = text + this.spans.add(QuoteSpan(color)) + init() + build() + }) +} + +fun Span.superscript(text: CharSequence = "", init: Span.() -> Unit = {}): Span = apply { + append(Span(parent = this).apply { + this.text = text + this.spans.add(SuperscriptSpan()) + init() + build() + }) +} + +fun Span.subscript(text: CharSequence = "", init: Span.() -> Unit = {}): Span = apply { + append(Span(parent = this).apply { + this.text = text + this.spans.add(SubscriptSpan()) + init() + build() + }) +} + +fun Span.image(drawable: Drawable, alignment: String = "bottom", + init: Span.() -> Unit = {}): Span = apply { + drawable.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight) + append(Span(parent = this).apply { + this.text = " " + this.spans.add(ImageSpan(drawable, when (alignment) { + "bottom" -> ImageSpan.ALIGN_BOTTOM + "baseline" -> ImageSpan.ALIGN_BASELINE + else -> throw RuntimeException("Unknown image alignment") + })) + init() + build() + }) +} + +fun Span.addSpan(what: Any) = apply { + this.spans.add(what) +} diff --git a/library/external/span/src/main/kotlin/me/gujun/android/span/style/CustomTypefaceSpan.kt b/library/external/span/src/main/kotlin/me/gujun/android/span/style/CustomTypefaceSpan.kt new file mode 100644 index 0000000000..c737c5bf11 --- /dev/null +++ b/library/external/span/src/main/kotlin/me/gujun/android/span/style/CustomTypefaceSpan.kt @@ -0,0 +1,36 @@ +package me.gujun.android.span.style + +import android.graphics.Paint +import android.graphics.Typeface +import android.text.TextPaint +import android.text.style.MetricAffectingSpan + +class CustomTypefaceSpan(private val tf: Typeface) : MetricAffectingSpan() { + + override fun updateMeasureState(paint: TextPaint) { + apply(paint, tf) + } + + override fun updateDrawState(ds: TextPaint) { + apply(ds, tf) + } + + private fun apply(paint: Paint, tf: Typeface) { + val oldStyle: Int + + val old = paint.typeface + oldStyle = old?.style ?: 0 + + val fake = oldStyle and tf.style.inv() + + if (fake and Typeface.BOLD != 0) { + paint.isFakeBoldText = true + } + + if (fake and Typeface.ITALIC != 0) { + paint.textSkewX = -0.25f + } + + paint.typeface = tf + } +} diff --git a/library/external/span/src/main/kotlin/me/gujun/android/span/style/LineSpacingSpan.kt b/library/external/span/src/main/kotlin/me/gujun/android/span/style/LineSpacingSpan.kt new file mode 100644 index 0000000000..53aa42aa83 --- /dev/null +++ b/library/external/span/src/main/kotlin/me/gujun/android/span/style/LineSpacingSpan.kt @@ -0,0 +1,31 @@ +package me.gujun.android.span.style + +import android.graphics.Paint.FontMetricsInt +import android.text.Spanned +import android.text.style.LineHeightSpan + +class LineSpacingSpan(private val add: Int) : LineHeightSpan { + + override fun chooseHeight(text: CharSequence, start: Int, end: Int, spanstartv: Int, v: Int, + fm: FontMetricsInt) { + text as Spanned + /*val spanStart =*/ text.getSpanStart(this) + val spanEnd = text.getSpanEnd(this) + +// Log.d("DEBUG", "Text: start=$start end=$end v=$v") // end may include the \n character +// Log.d("DEBUG", "${text.slice(start until end)}".replace("\n", "#")) +// Log.d("DEBUG", "LineSpacingSpan: spanStart=$spanStart spanEnd=$spanEnd spanstartv=$spanstartv") +// Log.d("DEBUG", "$fm") +// Log.d("DEBUG", "-----------------------") + + if (spanstartv == v) { + fm.descent += add + } else if (text[start - 1] == '\n') { + fm.descent += add + } + + if (end == spanEnd || end - 1 == spanEnd) { + fm.descent -= add + } + } +} diff --git a/library/external/span/src/main/kotlin/me/gujun/android/span/style/SimpleClickableSpan.kt b/library/external/span/src/main/kotlin/me/gujun/android/span/style/SimpleClickableSpan.kt new file mode 100644 index 0000000000..2ae524bca9 --- /dev/null +++ b/library/external/span/src/main/kotlin/me/gujun/android/span/style/SimpleClickableSpan.kt @@ -0,0 +1,10 @@ +package me.gujun.android.span.style + +import android.text.TextPaint +import android.text.style.ClickableSpan + +abstract class SimpleClickableSpan : ClickableSpan() { + override fun updateDrawState(ds: TextPaint) { + // no-op + } +} diff --git a/library/external/span/src/main/kotlin/me/gujun/android/span/style/TextDecorationLineSpan.kt b/library/external/span/src/main/kotlin/me/gujun/android/span/style/TextDecorationLineSpan.kt new file mode 100644 index 0000000000..ce37c138f7 --- /dev/null +++ b/library/external/span/src/main/kotlin/me/gujun/android/span/style/TextDecorationLineSpan.kt @@ -0,0 +1,29 @@ +package me.gujun.android.span.style + +import android.text.TextPaint +import android.text.style.CharacterStyle + +class TextDecorationLineSpan(private val textDecorationLine: String) : CharacterStyle() { + + override fun updateDrawState(tp: TextPaint) { + when (textDecorationLine) { + "none" -> { + tp.isUnderlineText = false + tp.isStrikeThruText = false + } + "underline" -> { + tp.isUnderlineText = true + tp.isStrikeThruText = false + } + "line-through" -> { + tp.isUnderlineText = false + tp.isStrikeThruText = true + } + "underline line-through" -> { + tp.isUnderlineText = true + tp.isStrikeThruText = true + } + else -> throw RuntimeException("Unknown text decoration line") + } + } +} diff --git a/library/external/span/src/main/kotlin/me/gujun/android/span/style/VerticalPaddingSpan.kt b/library/external/span/src/main/kotlin/me/gujun/android/span/style/VerticalPaddingSpan.kt new file mode 100644 index 0000000000..600ea72e57 --- /dev/null +++ b/library/external/span/src/main/kotlin/me/gujun/android/span/style/VerticalPaddingSpan.kt @@ -0,0 +1,41 @@ +package me.gujun.android.span.style + +import android.graphics.Paint.FontMetricsInt +import android.text.Spanned +import android.text.style.LineHeightSpan + +class VerticalPaddingSpan(private val paddingTop: Int, + private val paddingBottom: Int) : LineHeightSpan { + + private var flag: Boolean = true + + override fun chooseHeight(text: CharSequence, start: Int, end: Int, spanstartv: Int, v: Int, + fm: FontMetricsInt) { + text as Spanned + /*val spanStart =*/ text.getSpanStart(this) + val spanEnd = text.getSpanEnd(this) + +// Log.d("DEBUG", "Text: start=$start end=$end v=$v") // end may include the \n character +// Log.d("DEBUG", "${text.slice(start until end)}".replace("\n", "#")) +// Log.d("DEBUG", "VerticalPadding: spanStart=$spanStart spanEnd=$spanEnd spanstartv=$spanstartv") +// Log.d("DEBUG", "$fm") +// Log.d("DEBUG", "-----------------------") + + if (spanstartv == v) { + fm.top -= paddingTop + fm.ascent -= paddingTop + flag = true + } else if (flag && text[start - 1] != '\n') { + fm.top += paddingTop + fm.ascent += paddingTop + flag = false + } else { + flag = false + } + + if (end == spanEnd || end - 1 == spanEnd) { + fm.descent += paddingBottom + fm.bottom += paddingBottom + } + } +} diff --git a/library/external/textdrawable/build.gradle b/library/external/textdrawable/build.gradle new file mode 100644 index 0000000000..5eb27bf6aa --- /dev/null +++ b/library/external/textdrawable/build.gradle @@ -0,0 +1,25 @@ +apply plugin: 'com.android.library' + +apply plugin: 'com.android.library' + +android { + namespace "com.amulyakhare.textdrawable" + + compileSdk versions.compileSdk + + defaultConfig { + minSdk versions.minSdk + targetSdk versions.targetSdk + } + + compileOptions { + sourceCompatibility versions.sourceCompat + targetCompatibility versions.targetCompat + } +} + +afterEvaluate { + tasks.findAll { it.name.startsWith("lint") }.each { + it.enabled = false + } +} diff --git a/library/external/textdrawable/src/main/java/com/amulyakhare/textdrawable/TextDrawable.java b/library/external/textdrawable/src/main/java/com/amulyakhare/textdrawable/TextDrawable.java new file mode 100644 index 0000000000..db42f8b764 --- /dev/null +++ b/library/external/textdrawable/src/main/java/com/amulyakhare/textdrawable/TextDrawable.java @@ -0,0 +1,316 @@ +package com.amulyakhare.textdrawable; + +import android.graphics.*; +import android.graphics.drawable.ShapeDrawable; +import android.graphics.drawable.shapes.OvalShape; +import android.graphics.drawable.shapes.RectShape; +import android.graphics.drawable.shapes.RoundRectShape; + +/** + * @author amulya + * @datetime 14 Oct 2014, 3:53 PM + */ +public class TextDrawable extends ShapeDrawable { + + private final Paint textPaint; + private final Paint borderPaint; + private static final float SHADE_FACTOR = 0.9f; + private final String text; + private final int color; + private final RectShape shape; + private final int height; + private final int width; + private final int fontSize; + private final float radius; + private final int borderThickness; + + private TextDrawable(Builder builder) { + super(builder.shape); + + // shape properties + shape = builder.shape; + height = builder.height; + width = builder.width; + radius = builder.radius; + + // text and color + text = builder.toUpperCase ? builder.text.toUpperCase() : builder.text; + color = builder.color; + + // text paint settings + fontSize = builder.fontSize; + textPaint = new Paint(); + textPaint.setColor(builder.textColor); + textPaint.setAntiAlias(true); + textPaint.setFakeBoldText(builder.isBold); + textPaint.setStyle(Paint.Style.FILL); + textPaint.setTypeface(builder.font); + textPaint.setTextAlign(Paint.Align.CENTER); + textPaint.setStrokeWidth(builder.borderThickness); + + // border paint settings + borderThickness = builder.borderThickness; + borderPaint = new Paint(); + borderPaint.setColor(getDarkerShade(color)); + borderPaint.setStyle(Paint.Style.STROKE); + borderPaint.setStrokeWidth(borderThickness); + + // drawable paint color + Paint paint = getPaint(); + paint.setColor(color); + + } + + private int getDarkerShade(int color) { + return Color.rgb((int)(SHADE_FACTOR * Color.red(color)), + (int)(SHADE_FACTOR * Color.green(color)), + (int)(SHADE_FACTOR * Color.blue(color))); + } + + @Override + public void draw(Canvas canvas) { + super.draw(canvas); + Rect r = getBounds(); + + + // draw border + if (borderThickness > 0) { + drawBorder(canvas); + } + + int count = canvas.save(); + canvas.translate(r.left, r.top); + + // draw text + int width = this.width < 0 ? r.width() : this.width; + int height = this.height < 0 ? r.height() : this.height; + int fontSize = this.fontSize < 0 ? (Math.min(width, height) / 2) : this.fontSize; + textPaint.setTextSize(fontSize); + canvas.drawText(text, width / 2, height / 2 - ((textPaint.descent() + textPaint.ascent()) / 2), textPaint); + + canvas.restoreToCount(count); + + } + + private void drawBorder(Canvas canvas) { + RectF rect = new RectF(getBounds()); + rect.inset(borderThickness/2, borderThickness/2); + + if (shape instanceof OvalShape) { + canvas.drawOval(rect, borderPaint); + } + else if (shape instanceof RoundRectShape) { + canvas.drawRoundRect(rect, radius, radius, borderPaint); + } + else { + canvas.drawRect(rect, borderPaint); + } + } + + @Override + public void setAlpha(int alpha) { + textPaint.setAlpha(alpha); + } + + @Override + public void setColorFilter(ColorFilter cf) { + textPaint.setColorFilter(cf); + } + + @Override + public int getOpacity() { + return PixelFormat.TRANSLUCENT; + } + + @Override + public int getIntrinsicWidth() { + return width; + } + + @Override + public int getIntrinsicHeight() { + return height; + } + + public static IShapeBuilder builder() { + return new Builder(); + } + + public static class Builder implements IConfigBuilder, IShapeBuilder, IBuilder { + + private String text; + + private int color; + + private int borderThickness; + + private int width; + + private int height; + + private Typeface font; + + private RectShape shape; + + public int textColor; + + private int fontSize; + + private boolean isBold; + + private boolean toUpperCase; + + public float radius; + + private Builder() { + text = ""; + color = Color.GRAY; + textColor = Color.WHITE; + borderThickness = 0; + width = -1; + height = -1; + shape = new RectShape(); + font = Typeface.create("sans-serif-light", Typeface.NORMAL); + fontSize = -1; + isBold = false; + toUpperCase = false; + } + + public IConfigBuilder width(int width) { + this.width = width; + return this; + } + + public IConfigBuilder height(int height) { + this.height = height; + return this; + } + + public IConfigBuilder textColor(int color) { + this.textColor = color; + return this; + } + + public IConfigBuilder withBorder(int thickness) { + this.borderThickness = thickness; + return this; + } + + public IConfigBuilder useFont(Typeface font) { + this.font = font; + return this; + } + + public IConfigBuilder fontSize(int size) { + this.fontSize = size; + return this; + } + + public IConfigBuilder bold() { + this.isBold = true; + return this; + } + + public IConfigBuilder toUpperCase() { + this.toUpperCase = true; + return this; + } + + @Override + public IConfigBuilder beginConfig() { + return this; + } + + @Override + public IShapeBuilder endConfig() { + return this; + } + + @Override + public IBuilder rect() { + this.shape = new RectShape(); + return this; + } + + @Override + public IBuilder round() { + this.shape = new OvalShape(); + return this; + } + + @Override + public IBuilder roundRect(int radius) { + this.radius = radius; + float[] radii = {radius, radius, radius, radius, radius, radius, radius, radius}; + this.shape = new RoundRectShape(radii, null, null); + return this; + } + + @Override + public TextDrawable buildRect(String text, int color) { + rect(); + return build(text, color); + } + + @Override + public TextDrawable buildRoundRect(String text, int color, int radius) { + roundRect(radius); + return build(text, color); + } + + @Override + public TextDrawable buildRound(String text, int color) { + round(); + return build(text, color); + } + + @Override + public TextDrawable build(String text, int color) { + this.color = color; + this.text = text; + return new TextDrawable(this); + } + } + + public interface IConfigBuilder { + public IConfigBuilder width(int width); + + public IConfigBuilder height(int height); + + public IConfigBuilder textColor(int color); + + public IConfigBuilder withBorder(int thickness); + + public IConfigBuilder useFont(Typeface font); + + public IConfigBuilder fontSize(int size); + + public IConfigBuilder bold(); + + public IConfigBuilder toUpperCase(); + + public IShapeBuilder endConfig(); + } + + public static interface IBuilder { + + public TextDrawable build(String text, int color); + } + + public static interface IShapeBuilder { + + public IConfigBuilder beginConfig(); + + public IBuilder rect(); + + public IBuilder round(); + + public IBuilder roundRect(int radius); + + public TextDrawable buildRect(String text, int color); + + public TextDrawable buildRoundRect(String text, int color, int radius); + + public TextDrawable buildRound(String text, int color); + } +} \ No newline at end of file diff --git a/matrix-sdk-android/build.gradle b/matrix-sdk-android/build.gradle index 722b4fc0b3..6b29e56d77 100644 --- a/matrix-sdk-android/build.gradle +++ b/matrix-sdk-android/build.gradle @@ -189,7 +189,7 @@ dependencies { // Database implementation 'com.github.Zhuinden:realm-monarchy:0.7.1' - kapt 'dk.ilios:realmfieldnameshelper:2.0.0' + kapt project(":library:external:realmfieldnameshelper") // Shared Preferences implementation libs.androidx.preferenceKtx diff --git a/settings.gradle b/settings.gradle index ea20f12175..a0b9ce65ed 100644 --- a/settings.gradle +++ b/settings.gradle @@ -11,6 +11,12 @@ include ':library:multipicker' include ':library:external:jsonviewer' include ':library:external:diff-match-patch' include ':library:external:dialpad' +include ':library:external:textdrawable' +include ':library:external:autocomplete' +include ':library:external:realmfieldnameshelper' +include ':library:external:span' +include ':library:external:barcodescanner:core' +include ':library:external:barcodescanner:zxing' include ':library:rustCrypto' include ':matrix-sdk-android' diff --git a/vector-app/build.gradle b/vector-app/build.gradle index 10fe0c7525..36f1979531 100644 --- a/vector-app/build.gradle +++ b/vector-app/build.gradle @@ -396,6 +396,7 @@ dependencies { implementation project(':vector') implementation project(':vector-config') implementation project(':library:core-utils') + debugImplementation project(':library:external:span') debugImplementation project(':library:ui-styles') implementation libs.dagger.hilt implementation 'androidx.multidex:multidex:2.0.1' diff --git a/vector/build.gradle b/vector/build.gradle index dab8b8bdeb..eef4e333cf 100644 --- a/vector/build.gradle +++ b/vector/build.gradle @@ -116,6 +116,8 @@ dependencies { implementation project(":matrix-sdk-android-flow") implementation project(":library:external:jsonviewer") implementation project(":library:external:diff-match-patch") + implementation project(":library:external:textdrawable") + implementation project(":library:external:autocomplete") implementation project(":library:ui-strings") implementation project(":library:ui-styles") implementation project(":library:core-utils") @@ -184,11 +186,8 @@ dependencies { api libs.androidx.preferenceKtx // UI - implementation 'com.amulyakhare:com.amulyakhare.textdrawable:1.0.1' implementation libs.google.material - api('me.gujun.android:span:1.7') { - exclude group: 'com.android.support', module: 'support-annotations' - } + implementation project(":library:external:span") implementation libs.markwon.core implementation libs.markwon.extLatex implementation libs.markwon.imageGlide @@ -210,8 +209,6 @@ dependencies { // Alerter implementation 'com.github.tapadoo:alerter:7.2.4' - implementation 'com.otaliastudios:autocomplete:1.1.0' - // Shake detection implementation 'com.squareup:seismic:1.0.3' @@ -266,11 +263,7 @@ dependencies { // Stick to 3.3.3 because of https://github.com/zxing/zxing/issues/1170 implementation 'com.google.zxing:core:3.3.3' - // Excludes the legacy support library annotation usages - // https://github.com/dm77/barcodescanner/blob/d036996c8a6f36a68843ffe539c834c28944b2d5/core/src/main/java/me/dm7/barcodescanner/core/CameraWrapper.java#L4 - implementation ('me.dm7.barcodescanner:zxing:1.9.13') { - exclude group: 'com.android.support', module: 'support-v4' - } + implementation project(":library:external:barcodescanner:zxing") // Emoji Keyboard api libs.vanniktech.emojiMaterial diff --git a/vector/src/main/java/im/vector/app/features/autocomplete/RecyclerViewPresenter.kt b/vector/src/main/java/im/vector/app/features/autocomplete/RecyclerViewPresenter.kt index 7625eb6216..cd7ecb8a00 100644 --- a/vector/src/main/java/im/vector/app/features/autocomplete/RecyclerViewPresenter.kt +++ b/vector/src/main/java/im/vector/app/features/autocomplete/RecyclerViewPresenter.kt @@ -23,7 +23,7 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.otaliastudios.autocomplete.AutocompletePresenter -abstract class RecyclerViewPresenter(context: Context?) : AutocompletePresenter(context) { +abstract class RecyclerViewPresenter(context: Context) : AutocompletePresenter(context) { private var recyclerView: RecyclerView? = null private var clicks: ClickProvider? = null diff --git a/vector/src/main/java/im/vector/app/features/autocomplete/command/CommandAutocompletePolicy.kt b/vector/src/main/java/im/vector/app/features/autocomplete/command/CommandAutocompletePolicy.kt index 08f61be0f8..9097d2be43 100644 --- a/vector/src/main/java/im/vector/app/features/autocomplete/command/CommandAutocompletePolicy.kt +++ b/vector/src/main/java/im/vector/app/features/autocomplete/command/CommandAutocompletePolicy.kt @@ -32,16 +32,15 @@ class CommandAutocompletePolicy @Inject constructor() : AutocompletePolicy { return "" } - override fun onDismiss(text: Spannable?) { + override fun onDismiss(text: Spannable) { } // Only if text which starts with '/' and without space - override fun shouldShowPopup(text: Spannable?, cursorPos: Int): Boolean { - return enabled && text?.startsWith("/") == true && - !text.contains(" ") + override fun shouldShowPopup(text: Spannable, cursorPos: Int): Boolean { + return enabled && text.startsWith("/") && !text.contains(" ") } - override fun shouldDismissPopup(text: Spannable?, cursorPos: Int): Boolean { + override fun shouldDismissPopup(text: Spannable, cursorPos: Int): Boolean { return !shouldShowPopup(text, cursorPos) } }