diff --git a/android/build.gradle b/android/build.gradle index b80e9be95..aef6f6978 100755 --- a/android/build.gradle +++ b/android/build.gradle @@ -25,7 +25,7 @@ android { compileSdkVersion 29 defaultConfig { - minSdkVersion 17 + minSdkVersion 19 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true @@ -74,4 +74,4 @@ afterEvaluate { } } } -} \ No newline at end of file +} diff --git a/android/src/main/java/com/pichillilorenzo/flutter_inappwebview/InAppWebView/InAppWebView.java b/android/src/main/java/com/pichillilorenzo/flutter_inappwebview/InAppWebView/InAppWebView.java index c0faf56fc..2788dbfcd 100755 --- a/android/src/main/java/com/pichillilorenzo/flutter_inappwebview/InAppWebView/InAppWebView.java +++ b/android/src/main/java/com/pichillilorenzo/flutter_inappwebview/InAppWebView/InAppWebView.java @@ -5,19 +5,24 @@ import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Point; -import android.graphics.Rect; import android.os.Build; +import android.os.Handler; +import android.os.Looper; import android.print.PrintAttributes; import android.print.PrintDocumentAdapter; import android.print.PrintManager; import android.util.AttributeSet; import android.util.Log; +import android.view.ActionMode; import android.view.ContextMenu; +import android.view.GestureDetector; +import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuItem; import android.view.MotionEvent; import android.view.View; -import android.view.ViewParent; +import android.view.ViewGroup; +import android.view.ViewTreeObserver; import android.webkit.CookieManager; import android.webkit.DownloadListener; import android.webkit.ValueCallback; @@ -25,12 +30,9 @@ import android.webkit.WebHistoryItem; import android.webkit.WebSettings; import android.webkit.WebStorage; - -import androidx.annotation.RequiresApi; -import androidx.appcompat.widget.PopupMenu; - -import android.view.ActionMode; -import android.webkit.WebView; +import android.widget.HorizontalScrollView; +import android.widget.LinearLayout; +import android.widget.TextView; import com.pichillilorenzo.flutter_inappwebview.ContentBlocker.ContentBlocker; import com.pichillilorenzo.flutter_inappwebview.ContentBlocker.ContentBlockerAction; @@ -39,17 +41,22 @@ import com.pichillilorenzo.flutter_inappwebview.FlutterWebView; import com.pichillilorenzo.flutter_inappwebview.InAppBrowserActivity; import com.pichillilorenzo.flutter_inappwebview.JavaScriptBridgeInterface; +import com.pichillilorenzo.flutter_inappwebview.R; import com.pichillilorenzo.flutter_inappwebview.Shared; import com.pichillilorenzo.flutter_inappwebview.Util; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.regex.Pattern; +import androidx.annotation.RequiresApi; import io.flutter.plugin.common.MethodChannel; import okhttp3.OkHttpClient; @@ -72,6 +79,16 @@ final public class InAppWebView extends InputAwareWebView { int okHttpClientCacheSize = 10 * 1024 * 1024; // 10MB public ContentBlockerHandler contentBlockerHandler = new ContentBlockerHandler(); public Pattern regexToCancelSubFramesLoadingCompiled; + public GestureDetector gestureDetector = null; + public LinearLayout floatingContextMenu = null; + public Handler headlessHandler = new Handler(Looper.getMainLooper()); + + public Runnable checkScrollStoppedTask; + public int initialPositionScrollStoppedTask; + public int newCheckScrollStoppedTask = 100; // ms + + public Runnable checkContextMenuShouldBeClosedTask; + public int newCheckContextMenuShouldBeClosedTaskTask = 100; // ms static final String consoleLogJS = "(function(console) {" + " var oldLogs = {" + @@ -101,7 +118,7 @@ final public class InAppWebView extends InputAwareWebView { static final String printJS = "window.print = function() {" + " window." + JavaScriptBridgeInterface.name + ".callHandler('onPrint', window.location.href);" + - "}"; + "};"; static final String platformReadyJS = "window.dispatchEvent(new Event('flutterInAppWebViewPlatformReady'));"; @@ -515,6 +532,56 @@ final public class InAppWebView extends InputAwareWebView { " };" + "})(window.fetch);"; + static final String isActiveElementInputEditableJS = + "var activeEl = document.activeElement;" + + "var nodeName = (activeEl != null) ? activeEl.nodeName.toLowerCase() : '';" + + "var isActiveElementInputEditable = activeEl != null && " + + "(activeEl.nodeType == 1 && (nodeName == 'textarea' || (nodeName == 'input' && /^(?:text|email|number|search|tel|url|password)$/i.test(activeEl.type != null ? activeEl.type : 'text')))) && " + + "!activeEl.disabled && !activeEl.readOnly;" + + "var isActiveElementEditable = isActiveElementInputEditable || (activeEl != null && activeEl.isContentEditable) || document.designMode === 'on';"; + + // android Workaround to hide context menu when selected text is empty + // and the document active element is not an input element. + static final String checkContextMenuShouldBeHiddenJS = "(function(){" + + " var txt;" + + " if (window.getSelection) {" + + " txt = window.getSelection().toString();" + + " } else if (window.document.getSelection) {" + + " txt = window.document.getSelection().toString();" + + " } else if (window.document.selection) {" + + " txt = window.document.selection.createRange().text;" + + " }" + + isActiveElementInputEditableJS + + " return txt === '' && !isActiveElementEditable;" + + "})();"; + + // android Workaround to hide context menu when user emit a keydown event + static final String checkGlobalKeyDownEventToHideContextMenuJS = "(function(){" + + " document.addEventListener('keydown', function(e) {" + + " window." + JavaScriptBridgeInterface.name + "._hideContextMenu();" + + " });" + + "})();"; + + // android Workaround to hide the Keyboard when the user click outside + // on something not focusable such as input or a textarea. + static final String androidKeyboardWorkaroundFocusoutEventJS = "(function(){" + + " var isFocusin = false;" + + " document.addEventListener('focusin', function(e) {" + + " var nodeName = e.target.nodeName.toLowerCase();" + + " var isInputButton = nodeName === 'input' && e.target.type != null && e.target.type === 'button';" + + " isFocusin = (['a', 'area', 'button', 'details', 'iframe', 'select', 'summary'].indexOf(nodeName) >= 0 || isInputButton) ? false : true;" + + " });" + + " document.addEventListener('focusout', function(e) {" + + " isFocusin = false;" + + " setTimeout(function() {" + + isActiveElementInputEditableJS + + " if (!isFocusin && !isActiveElementEditable) {" + + " window." + JavaScriptBridgeInterface.name + ".callHandler('androidKeyboardWorkaroundFocusoutEvent');" + + " }" + + " }, 300);" + + " });" + + "})();"; + public InAppWebView(Context context) { super(context); } @@ -536,7 +603,6 @@ else if (obj instanceof FlutterWebView) this.channel = (this.inAppBrowserActivity != null) ? this.inAppBrowserActivity.channel : this.flutterWebView.channel; this.id = id; this.options = options; - //Shared.activity.registerForContextMenu(this); } @Override @@ -689,12 +755,62 @@ public void onFindResultReceived(int activeMatchOrdinal, int numberOfMatches, bo } }); - setOnTouchListener(new View.OnTouchListener() { + gestureDetector = new GestureDetector(this.getContext(), new GestureDetector.SimpleOnGestureListener() { + @Override + public boolean onSingleTapUp(MotionEvent ev) { + if (floatingContextMenu != null) { + hideContextMenu(); + } + return super.onSingleTapUp(ev); + } + }); + + checkScrollStoppedTask = new Runnable() { + @Override + public void run() { + int newPosition = getScrollY(); + if(initialPositionScrollStoppedTask - newPosition == 0){ + // has stopped + onScrollStopped(); + } else { + initialPositionScrollStoppedTask = getScrollY(); + headlessHandler.postDelayed(checkScrollStoppedTask, newCheckScrollStoppedTask); + } + } + }; + + checkContextMenuShouldBeClosedTask = new Runnable() { + @Override + public void run() { + if (floatingContextMenu != null) { + evaluateJavascript(checkContextMenuShouldBeHiddenJS, new ValueCallback() { + @Override + public void onReceiveValue(String value) { + if (value == null || value.equals("true")) { + if (floatingContextMenu != null) { + hideContextMenu(); + } + } else { + headlessHandler.postDelayed(checkContextMenuShouldBeClosedTask, newCheckContextMenuShouldBeClosedTaskTask); + } + } + }); + } + } + }; + + setOnTouchListener(new OnTouchListener() { float m_downX; float m_downY; @Override public boolean onTouch(View v, MotionEvent event) { + gestureDetector.onTouchEvent(event); + + if (event.getAction() == MotionEvent.ACTION_UP) { + checkScrollStoppedTask.run(); + } + if (options.disableHorizontalScroll && options.disableVerticalScroll) { return (event.getAction() == MotionEvent.ACTION_MOVE); } @@ -743,13 +859,7 @@ public boolean onLongClick(View v) { }); } - private Point lastTouch; - - @Override - public boolean onTouchEvent(MotionEvent ev) { - lastTouch = new Point((int) ev.getX(), (int) ev.getY()) ; - return super.onTouchEvent(ev); - } + private MotionEvent lastMotionEvent = null; public void setIncognito(boolean enabled) { WebSettings settings = getSettings(); @@ -888,7 +998,7 @@ public void clearAllCache() { } public void takeScreenshot(final MethodChannel.Result result) { - post(new Runnable() { + headlessHandler.post(new Runnable() { @Override public void run() { int height = (int) (getContentHeight() * scale + 0.5); @@ -1190,7 +1300,7 @@ public void injectDeferredObject(String source, String jsWrapper, final MethodCh scriptToInject = String.format(jsWrapper, jsonSourceString); } final String finalScriptToInject = scriptToInject; - post(new Runnable() { + headlessHandler.post(new Runnable() { @Override public void run() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) { @@ -1266,6 +1376,11 @@ protected void onScrollChanged (int l, int x = (int) (l/scale); int y = (int) (t/scale); + if (floatingContextMenu != null) { + floatingContextMenu.setAlpha(0f); + floatingContextMenu.setVisibility(View.GONE); + } + Map obj = new HashMap<>(); if (inAppBrowserActivity != null) obj.put("uuid", inAppBrowserActivity.uuid); @@ -1335,82 +1450,206 @@ public void printCurrentPage() { PrintManager printManager = (PrintManager) Shared.activity.getApplicationContext() .getSystemService(Context.PRINT_SERVICE); - String jobName = getTitle() + " Document"; + if (printManager != null) { + String jobName = getTitle() + " Document"; - // Get a printCurrentPage adapter instance - PrintDocumentAdapter printAdapter = createPrintDocumentAdapter(jobName); + // Get a printCurrentPage adapter instance + PrintDocumentAdapter printAdapter = createPrintDocumentAdapter(jobName); - // Create a printCurrentPage job with name and adapter instance - printManager.print(jobName, printAdapter, - new PrintAttributes.Builder().build()); + // Create a printCurrentPage job with name and adapter instance + printManager.print(jobName, printAdapter, + new PrintAttributes.Builder().build()); + } else { + Log.e(LOG_TAG, "No PrintManager available"); + } } public Float getUpdatedScale() { return scale; } -/* + + private Point contextMenuPoint = new Point(0, 0); + private Point lastTouch = new Point(0, 0); + @Override - public void onCreateContextMenu(ContextMenu menu) { - Log.d(LOG_TAG, getHitTestResult().getType() + ""); - String extra = getHitTestResult().getExtra(); - //if (getHitTestResult().getType() == 7 || getHitTestResult().getType() == 5) - if (extra != null) - Log.d(LOG_TAG, extra); - Log.d(LOG_TAG, "\n\nonCreateContextMenu\n\n"); - - for(int i = 0; i < menu.size(); i++) { - Log.d(LOG_TAG, menu.getItem(i).toString()); - } + public boolean onTouchEvent(MotionEvent ev) { + lastTouch = new Point((int) ev.getX(), (int) ev.getY()); + return super.onTouchEvent(ev); } - private Integer mActionMode; - private CustomActionModeCallback mActionModeCallback; + @Override + public ActionMode startActionMode(ActionMode.Callback callback) { + return rebuildActionMode(super.startActionMode(callback), callback); + } + @RequiresApi(api = Build.VERSION_CODES.M) @Override - public ActionMode startActionMode(ActionMode.Callback callback, int mode) { - Log.d(LOG_TAG, "startActionMode"); - ViewParent parent = getParent(); - if (parent == null || mActionMode != null) { - return null; + public ActionMode startActionMode(ActionMode.Callback callback, int type) { + return rebuildActionMode(super.startActionMode(callback, type), callback); + } + + public ActionMode rebuildActionMode( + final ActionMode actionMode, + final ActionMode.Callback callback + ) { + if (floatingContextMenu != null) { + hideContextMenu(); } - mActionModeCallback = new CustomActionModeCallback(); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - mActionMode = ActionMode.TYPE_FLOATING; - //return Shared.activity.getWindow().getDecorView().startActionMode(mActionModeCallback, mActionMode); - return parent.startActionModeForChild(this, mActionModeCallback, mActionMode); - } else { - return parent.startActionModeForChild(this, mActionModeCallback); + if (actionMode == null) { + return null; } - } - private class CustomActionModeCallback implements ActionMode.Callback { + // We only support copy, cut, paste, and selectAll action. For other actions provided by other + // applications, the callback.onActionItemClicked(actionMode, menuItem) causes crash due to the + // following error: + // + // java.lang.RuntimeException: This method is only implemented for Activity-based Contexts. + // Check canStartActivityForResult() before calling. + // at android.content.Context.startActivityForResult(Context.java:1774) + // ... + final Set supportedActionSet = new HashSet<>(Arrays.asList( + getContext().getString(android.R.string.copy), + getContext().getString(android.R.string.cut), + getContext().getString(android.R.string.paste), + getContext().getString(android.R.string.selectAll) + )); + + floatingContextMenu = (LinearLayout) LayoutInflater.from(this.getContext()) + .inflate(R.layout.floating_action_mode, this, false); + HorizontalScrollView horizontalScrollView = (HorizontalScrollView) floatingContextMenu.getChildAt(0); + LinearLayout menuItemListLayout = (LinearLayout) horizontalScrollView.getChildAt(0); + + Menu actionMenu = actionMode.getMenu(); + for (int i = 0; i < actionMenu.size(); i++) { + final MenuItem menuItem = actionMenu.getItem(i); + final String itemTitle = menuItem.getTitle().toString(); + + if (!supportedActionSet.contains(itemTitle)) { + continue; + } - @Override - public boolean onCreateActionMode(ActionMode mode, Menu menu) { + TextView text = (TextView) LayoutInflater.from(this.getContext()) + .inflate(R.layout.floating_action_mode_item, this, false); + text.setText(itemTitle); + text.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + hideContextMenu(); + callback.onActionItemClicked(actionMode, menuItem); + } + }); + if (floatingContextMenu != null) { + menuItemListLayout.addView(text); + } + } - menu.add("ciao"); + final int x = (lastTouch != null) ? lastTouch.x : 0; + final int y = (lastTouch != null) ? lastTouch.y : 0; + contextMenuPoint = new Point(x, y); - return true; - } + if (floatingContextMenu != null) { + floatingContextMenu.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { - @Override - public boolean onPrepareActionMode(ActionMode mode, Menu menu) { - return false; + @Override + public void onGlobalLayout() { + if (floatingContextMenu != null) { + floatingContextMenu.getViewTreeObserver().removeOnGlobalLayoutListener(this); + if (getSettings().getJavaScriptEnabled()) { + onScrollStopped(); + } else { + onFloatingActionGlobalLayout(x, y); + } + } + } + }); + addView(floatingContextMenu, new LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, x, y)); + if (checkContextMenuShouldBeClosedTask != null) { + checkContextMenuShouldBeClosedTask.run(); + } } + actionMenu.clear(); - @Override - public boolean onActionItemClicked(ActionMode mode, MenuItem item) { - mode.finish(); - return true; + return actionMode; + } + + public void onFloatingActionGlobalLayout(int x, int y) { + int maxWidth = getWidth(); + int maxHeight = getHeight(); + int width = floatingContextMenu.getWidth(); + int height = floatingContextMenu.getHeight(); + int curx = x - (width / 2); + if (curx < 0) { + curx = 0; + } else if (curx + width > maxWidth) { + curx = maxWidth - width; + } + // float size = 12 * scale; + float cury = y - (height * 1.5f); + if (cury < 0) { + cury = y + height; } - // Called when the user exits the action mode - @Override - public void onDestroyActionMode(ActionMode mode) { - clearFocus(); + updateViewLayout( + floatingContextMenu, + new LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, curx, ((int) cury) + getScrollY()) + ); + + headlessHandler.post(new Runnable() { + @Override + public void run() { + if (floatingContextMenu != null) { + floatingContextMenu.setVisibility(View.VISIBLE); + floatingContextMenu.animate().alpha(1f).setDuration(100).setListener(null); + } + } + }); + } + + public void hideContextMenu() { + removeView(floatingContextMenu); + floatingContextMenu = null; + } + + public void onScrollStopped() { + if (floatingContextMenu != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + adjustFloatingContextMenuPosition(); } } -*/ + + @RequiresApi(api = Build.VERSION_CODES.KITKAT) + public void adjustFloatingContextMenuPosition() { + evaluateJavascript("(function(){" + + " var selection = window.getSelection();" + + " var rangeY = null;" + + " if (selection != null && selection.rangeCount > 0) {" + + " var range = selection.getRangeAt(0);" + + " var clientRect = range.getClientRects();" + + " if (clientRect.length > 0) {" + + " rangeY = clientRect[0].y;" + + " } else if (document.activeElement) {" + + " var boundingClientRect = document.activeElement.getBoundingClientRect();" + + " rangeY = boundingClientRect.y;" + + " }" + + " }" + + " return rangeY;" + + "})();", new ValueCallback() { + @Override + public void onReceiveValue(String value) { + if (floatingContextMenu != null) { + if (value != null) { + int x = contextMenuPoint.x; + int y = (int) ((Float.parseFloat(value) * scale) + (floatingContextMenu.getHeight() / 3.5)); + contextMenuPoint.y = y; + onFloatingActionGlobalLayout(x, y); + } else { + floatingContextMenu.setVisibility(View.VISIBLE); + floatingContextMenu.animate().alpha(1f).setDuration(100).setListener(null); + } + } + } + }); + } + @Override public void dispose() { super.dispose(); @@ -1418,6 +1657,7 @@ public void dispose() { @Override public void destroy() { + headlessHandler.removeCallbacksAndMessages(null); super.destroy(); } } diff --git a/android/src/main/java/com/pichillilorenzo/flutter_inappwebview/InAppWebView/InAppWebViewClient.java b/android/src/main/java/com/pichillilorenzo/flutter_inappwebview/InAppWebView/InAppWebViewClient.java index 023e8f961..e35ef824f 100755 --- a/android/src/main/java/com/pichillilorenzo/flutter_inappwebview/InAppWebView/InAppWebViewClient.java +++ b/android/src/main/java/com/pichillilorenzo/flutter_inappwebview/InAppWebView/InAppWebViewClient.java @@ -181,6 +181,10 @@ public void onPageStarted(WebView view, String url, Bitmap favicon) { if (webView.options.useOnLoadResource) { js += InAppWebView.resourceObserverJS.replaceAll("[\r\n]+", ""); } + js += InAppWebView.checkGlobalKeyDownEventToHideContextMenuJS.replaceAll("[\r\n]+", ""); + if (flutterWebView != null) { + js += InAppWebView.androidKeyboardWorkaroundFocusoutEventJS.replaceAll("[\r\n]+", ""); + } js += InAppWebView.printJS.replaceAll("[\r\n]+", ""); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { diff --git a/android/src/main/java/com/pichillilorenzo/flutter_inappwebview/JavaScriptBridgeInterface.java b/android/src/main/java/com/pichillilorenzo/flutter_inappwebview/JavaScriptBridgeInterface.java index 0ab277c13..9811fa347 100755 --- a/android/src/main/java/com/pichillilorenzo/flutter_inappwebview/JavaScriptBridgeInterface.java +++ b/android/src/main/java/com/pichillilorenzo/flutter_inappwebview/JavaScriptBridgeInterface.java @@ -42,6 +42,21 @@ else if (obj instanceof FlutterWebView) this.channel = (this.inAppBrowserActivity != null) ? this.inAppBrowserActivity.channel : this.flutterWebView.channel; } + @JavascriptInterface + public void _hideContextMenu() { + final InAppWebView webView = (inAppBrowserActivity != null) ? inAppBrowserActivity.webView : flutterWebView.webView; + + final Handler handler = new Handler(Looper.getMainLooper()); + handler.post(new Runnable() { + @Override + public void run() { + if (webView != null && webView.floatingContextMenu != null) { + webView.hideContextMenu(); + } + } + }); + } + @JavascriptInterface public void _callHandler(final String handlerName, final String _callHandlerID, final String args) { final InAppWebView webView = (inAppBrowserActivity != null) ? inAppBrowserActivity.webView : flutterWebView.webView; diff --git a/android/src/main/res/drawable/floating_action_mode_shape.xml b/android/src/main/res/drawable/floating_action_mode_shape.xml new file mode 100644 index 000000000..9ad086b97 --- /dev/null +++ b/android/src/main/res/drawable/floating_action_mode_shape.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/android/src/main/res/layout/floating_action_mode.xml b/android/src/main/res/layout/floating_action_mode.xml new file mode 100644 index 000000000..b58cc7cbc --- /dev/null +++ b/android/src/main/res/layout/floating_action_mode.xml @@ -0,0 +1,16 @@ + + + + + + \ No newline at end of file diff --git a/android/src/main/res/layout/floating_action_mode_item.xml b/android/src/main/res/layout/floating_action_mode_item.xml new file mode 100644 index 000000000..3ce3c1117 --- /dev/null +++ b/android/src/main/res/layout/floating_action_mode_item.xml @@ -0,0 +1,12 @@ + + \ No newline at end of file