// SPDX-License-Identifier: Unlicense OR MIT

package org.gioui;

import java.lang.Class;
import java.lang.IllegalAccessException;
import java.lang.InstantiationException;
import java.lang.ExceptionInInitializerError;
import java.lang.SecurityException;
import android.app.Activity;
import android.app.Fragment;
import android.app.FragmentManager;
import android.app.FragmentTransaction;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Matrix;
import android.graphics.Rect;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.SystemClock;
import android.text.TextUtils;
import android.text.Selection;
import android.text.SpannableStringBuilder;
import android.util.AttributeSet;
import android.util.TypedValue;
import android.view.Choreographer;
import android.view.Display;
import android.view.KeyCharacterMap;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.PointerIcon;
import android.view.View;
import android.view.ViewConfiguration;
import android.view.WindowInsets;
import android.view.Surface;
import android.view.SurfaceView;
import android.view.SurfaceHolder;
import android.view.Window;
import android.view.WindowInsetsController;
import android.view.WindowManager;
import android.view.inputmethod.CorrectionInfo;
import android.view.inputmethod.CompletionInfo;
import android.view.inputmethod.CursorAnchorInfo;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.ExtractedText;
import android.view.inputmethod.ExtractedTextRequest;
import android.view.inputmethod.InputConnection;
import android.view.inputmethod.InputMethodManager;
import android.view.inputmethod.InputContentInfo;
import android.view.inputmethod.SurroundingText;
import android.view.accessibility.AccessibilityNodeProvider;
import android.view.accessibility.AccessibilityNodeInfo;
import android.view.accessibility.AccessibilityEvent;
import android.view.accessibility.AccessibilityManager;

import java.io.UnsupportedEncodingException;

public final class GioView extends SurfaceView implements Choreographer.FrameCallback {
	private static boolean jniLoaded;

	private final SurfaceHolder.Callback surfCallbacks;
	private final View.OnFocusChangeListener focusCallback;
	private final InputMethodManager imm;
	private final float scrollXScale;
	private final float scrollYScale;
	private final AccessibilityManager accessManager;
	private int keyboardHint;

	private long nhandle;

	public GioView(Context context) {
		this(context, null);
	}

	public GioView(Context context, AttributeSet attrs) {
		super(context, attrs);
		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
			setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_LAYOUT_STABLE);
		}
		setLayoutParams(new WindowManager.LayoutParams(WindowManager.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.MATCH_PARENT));

		// Late initialization of the Go runtime to wait for a valid context.
		Gio.init(context.getApplicationContext());

		// Set background color to transparent to avoid a flickering
		// issue on ChromeOS.
		setBackgroundColor(Color.argb(0, 0, 0, 0));

		ViewConfiguration conf = ViewConfiguration.get(context);
		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
			scrollXScale = conf.getScaledHorizontalScrollFactor();
			scrollYScale = conf.getScaledVerticalScrollFactor();

			// The platform focus highlight is not aware of Gio's widgets.
			setDefaultFocusHighlightEnabled(false);
		} else {
			float listItemHeight = 48; // dp
			float px = TypedValue.applyDimension(
				TypedValue.COMPLEX_UNIT_DIP,
				listItemHeight,
				getResources().getDisplayMetrics()
			);
			scrollXScale = px;
			scrollYScale = px;
		}

		setHighRefreshRate();

		accessManager = (AccessibilityManager)context.getSystemService(Context.ACCESSIBILITY_SERVICE);
		imm = (InputMethodManager)context.getSystemService(Context.INPUT_METHOD_SERVICE);
		nhandle = onCreateView(this);
		setFocusable(true);
		setFocusableInTouchMode(true);
		focusCallback = new View.OnFocusChangeListener() {
			@Override public void onFocusChange(View v, boolean focus) {
				GioView.this.onFocusChange(nhandle, focus);
			}
		};
		setOnFocusChangeListener(focusCallback);
		surfCallbacks = new SurfaceHolder.Callback() {
			@Override public void surfaceCreated(SurfaceHolder holder) {
				// Ignore; surfaceChanged is guaranteed to be called immediately after this.
			}
			@Override public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
				onSurfaceChanged(nhandle, getHolder().getSurface());
			}
			@Override public void surfaceDestroyed(SurfaceHolder holder) {
				onSurfaceDestroyed(nhandle);
			}
		};
		getHolder().addCallback(surfCallbacks);
	}

	@Override public boolean onKeyDown(int keyCode, KeyEvent event) {
		if (nhandle != 0) {
			onKeyEvent(nhandle, keyCode, event.getUnicodeChar(), true, event.getEventTime());
		}
		return false;
	}

	@Override public boolean onKeyUp(int keyCode, KeyEvent event) {
		if (nhandle != 0) {
			onKeyEvent(nhandle, keyCode, event.getUnicodeChar(), false, event.getEventTime());
		}
		return false;
	}

	@Override public boolean onGenericMotionEvent(MotionEvent event) {
		dispatchMotionEvent(event);
		return true;
	}

	@Override public boolean onTouchEvent(MotionEvent event) {
		// Ask for unbuffered events. Flutter and Chrome do it
		// so assume it's good for us as well.
		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
			requestUnbufferedDispatch(event);
		}

		dispatchMotionEvent(event);
		return true;
	}

	private void setCursor(int id) {
		if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
			return;
		}
		PointerIcon pointerIcon = PointerIcon.getSystemIcon(getContext(), id);
		setPointerIcon(pointerIcon);
	}

	private void setOrientation(int id, int fallback) {
		if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR2) {
			id = fallback;
		}
		((Activity) this.getContext()).setRequestedOrientation(id);
	}

	private void setFullscreen(boolean enabled) {
		int flags = this.getSystemUiVisibility();
		if (enabled) {
		   flags |= SYSTEM_UI_FLAG_IMMERSIVE_STICKY;
		   flags |= SYSTEM_UI_FLAG_HIDE_NAVIGATION;
		   flags |= SYSTEM_UI_FLAG_FULLSCREEN;
		   flags |= SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN;
		} else {
		   flags &= ~SYSTEM_UI_FLAG_IMMERSIVE_STICKY;
		   flags &= ~SYSTEM_UI_FLAG_HIDE_NAVIGATION;
		   flags &= ~SYSTEM_UI_FLAG_FULLSCREEN;
		   flags &= ~SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN;
		}
		this.setSystemUiVisibility(flags);
	}

	private enum Bar {
		NAVIGATION,
		STATUS,
	}

	private void setBarColor(Bar t, int color, int luminance) {
		if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
			return;
		}

		Window window = ((Activity) this.getContext()).getWindow();

		int insetsMask;
		int viewMask;

		switch (t) {
		case STATUS:
			insetsMask = WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS;
			viewMask = View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR;
			window.setStatusBarColor(color);
			break;
		case NAVIGATION:
			insetsMask = WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS;
			viewMask = View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR;
			window.setNavigationBarColor(color);
			break;
		default:
			throw new RuntimeException("invalid bar type");
		}

		if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
			return;
		}

		if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
			int flags = this.getSystemUiVisibility();
			if (luminance > 128) {
			   flags |=  viewMask;
			} else {
			   flags &= ~viewMask;
			}
			this.setSystemUiVisibility(flags);
			return;
		}

		WindowInsetsController insetsController = window.getInsetsController();
		if (insetsController == null) {
			return;
		}
		if (luminance > 128) {
			insetsController.setSystemBarsAppearance(insetsMask, insetsMask);
		} else {
			insetsController.setSystemBarsAppearance(0, insetsMask);
		}
	}

	private void setStatusColor(int color, int luminance) {
		this.setBarColor(Bar.STATUS, color, luminance);
	}

	private void setNavigationColor(int color, int luminance) {
		this.setBarColor(Bar.NAVIGATION, color, luminance);
	}

	private void setHighRefreshRate() {
		if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
			return;
		}

		Context context = getContext();
		Display display = context.getDisplay();
		Display.Mode[] supportedModes = display.getSupportedModes();
		if (supportedModes.length <= 1) {
			// Nothing to set
			return;
		}

		Display.Mode currentMode = display.getMode();
		int currentWidth = currentMode.getPhysicalWidth();
		int currentHeight = currentMode.getPhysicalHeight();

		float minRefreshRate = -1;
		float maxRefreshRate = -1;
		float bestRefreshRate = -1;
		int bestModeId = -1;
		for (Display.Mode mode : supportedModes) {
			float refreshRate = mode.getRefreshRate();
			float width = mode.getPhysicalWidth();
			float height = mode.getPhysicalHeight();

			if (minRefreshRate == -1 || refreshRate < minRefreshRate) {
				minRefreshRate = refreshRate;
			}
			if (maxRefreshRate == -1 || refreshRate > maxRefreshRate) {
				maxRefreshRate = refreshRate;
			}

			boolean refreshRateIsBetter = bestRefreshRate == -1 || refreshRate > bestRefreshRate;
			if (width == currentWidth && height == currentHeight && refreshRateIsBetter) {
				int modeId = mode.getModeId();
				bestRefreshRate = refreshRate;
				bestModeId = modeId;
			}
		}

		if (bestModeId == -1) {
			// Not expecting this but just in case
			return;
		}

		if (minRefreshRate == maxRefreshRate) {
			// Can't improve the refresh rate
			return;
		}

		Window window = ((Activity) context).getWindow();
		WindowManager.LayoutParams layoutParams = window.getAttributes();
		layoutParams.preferredDisplayModeId = bestModeId;
		window.setAttributes(layoutParams);
	}

	@Override protected boolean dispatchHoverEvent(MotionEvent event) {
		if (!accessManager.isTouchExplorationEnabled()) {
			return super.dispatchHoverEvent(event);
		}
		switch (event.getAction()) {
		case MotionEvent.ACTION_HOVER_ENTER:
			// Fall through.
		case MotionEvent.ACTION_HOVER_MOVE:
			onTouchExploration(nhandle, event.getX(), event.getY());
			break;
		case MotionEvent.ACTION_HOVER_EXIT:
			onExitTouchExploration(nhandle);
			break;
		}
		return true;
	}

	void sendA11yEvent(int eventType, int viewId) {
		if (!accessManager.isEnabled()) {
			return;
		}
		AccessibilityEvent event = obtainA11yEvent(eventType, viewId);
		getParent().requestSendAccessibilityEvent(this, event);
	}

	AccessibilityEvent obtainA11yEvent(int eventType, int viewId) {
		AccessibilityEvent event = AccessibilityEvent.obtain(eventType);
		event.setPackageName(getContext().getPackageName());
		event.setSource(this, viewId);
		return event;
	}

	boolean isA11yActive() {
		return accessManager.isEnabled();
	}

	void sendA11yChange(int viewId) {
		if (!accessManager.isEnabled()) {
			return;
		}
		AccessibilityEvent event = obtainA11yEvent(AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED, viewId);
		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
			event.setContentChangeTypes(AccessibilityEvent.CONTENT_CHANGE_TYPE_SUBTREE);
		}
		getParent().requestSendAccessibilityEvent(this, event);
	}

	private void dispatchMotionEvent(MotionEvent event) {
		if (nhandle == 0) {
			return;
		}
		for (int j = 0; j < event.getHistorySize(); j++) {
			long time = event.getHistoricalEventTime(j);
			for (int i = 0; i < event.getPointerCount(); i++) {
				onTouchEvent(
						nhandle,
						event.ACTION_MOVE,
						event.getPointerId(i),
						event.getToolType(i),
						event.getHistoricalX(i, j),
						event.getHistoricalY(i, j),
						scrollXScale*event.getHistoricalAxisValue(MotionEvent.AXIS_HSCROLL, i, j),
						scrollYScale*event.getHistoricalAxisValue(MotionEvent.AXIS_VSCROLL, i, j),
						event.getButtonState(),
						time);
			}
		}
		int act = event.getActionMasked();
		int idx = event.getActionIndex();
		for (int i = 0; i < event.getPointerCount(); i++) {
			int pact = event.ACTION_MOVE;
			if (i == idx) {
				pact = act;
			}
			onTouchEvent(
					nhandle,
					pact,
					event.getPointerId(i),
					event.getToolType(i),
					event.getX(i), event.getY(i),
					scrollXScale*event.getAxisValue(MotionEvent.AXIS_HSCROLL, i),
					scrollYScale*event.getAxisValue(MotionEvent.AXIS_VSCROLL, i),
					event.getButtonState(),
					event.getEventTime());
		}
	}

	@Override public InputConnection onCreateInputConnection(EditorInfo editor) {
		Snippet snip = getSnippet();
		editor.inputType = this.keyboardHint;
		editor.imeOptions = EditorInfo.IME_FLAG_NO_FULLSCREEN | EditorInfo.IME_FLAG_NO_EXTRACT_UI;
		editor.initialSelStart = imeToUTF16(nhandle, imeSelectionStart(nhandle));
		editor.initialSelEnd = imeToUTF16(nhandle, imeSelectionEnd(nhandle));
		int selStart = editor.initialSelStart - snip.offset;
		editor.initialCapsMode = TextUtils.getCapsMode(snip.snippet, selStart, this.keyboardHint);
		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
			editor.setInitialSurroundingSubText(snip.snippet, imeToUTF16(nhandle, snip.offset));
		}
		imeSetComposingRegion(nhandle, -1, -1);
		return new GioInputConnection();
	}

	void setInputHint(int hint) {
		if (hint == this.keyboardHint) {
			return;
		}
		this.keyboardHint = hint;
		restartInput();
	}

	void showTextInput() {
		GioView.this.requestFocus();
		imm.showSoftInput(GioView.this, 0);
	}

	void hideTextInput() {
		imm.hideSoftInputFromWindow(getWindowToken(), 0);
	}

	@Override protected boolean fitSystemWindows(Rect insets) {
		if (nhandle != 0) {
			onWindowInsets(nhandle, insets.top, insets.right, insets.bottom, insets.left);
		}
		return true;
	}

	void postFrameCallback() {
		Choreographer.getInstance().removeFrameCallback(this);
		Choreographer.getInstance().postFrameCallback(this);
	}

	@Override public void doFrame(long nanos) {
		if (nhandle != 0) {
			onFrameCallback(nhandle);
		}
	}

	int getDensity() {
		return getResources().getDisplayMetrics().densityDpi;
	}

	float getFontScale() {
		return getResources().getConfiguration().fontScale;
	}

	public void start() {
		if (nhandle != 0) {
			onStartView(nhandle);
		}
	}

	public void stop() {
		if (nhandle != 0) {
			onStopView(nhandle);
		}
	}

	public void destroy() {
		if (nhandle != 0) {
			onDestroyView(nhandle);
		}
	}

	protected void unregister() {
		setOnFocusChangeListener(null);
		getHolder().removeCallback(surfCallbacks);
		nhandle = 0;
	}

	public void configurationChanged() {
		if (nhandle != 0) {
			onConfigurationChanged(nhandle);
		}
	}

	public boolean backPressed() {
		if (nhandle == 0) {
			return false;
		}
		return onBack(nhandle);
	}

	void restartInput() {
		imm.restartInput(this);
	}

	void updateSelection() {
		int selStart = imeToUTF16(nhandle, imeSelectionStart(nhandle));
		int selEnd = imeToUTF16(nhandle, imeSelectionEnd(nhandle));
		int compStart = imeToUTF16(nhandle, imeComposingStart(nhandle));
		int compEnd = imeToUTF16(nhandle, imeComposingEnd(nhandle));
		imm.updateSelection(this, selStart, selEnd, compStart, compEnd);
	}

	void updateCaret(float m00, float m01, float m02, float m10, float m11, float m12, float caretX, float caretTop, float caretBase, float caretBottom) {
		if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
			return;
		}
		Matrix m = new Matrix();
		m.setValues(new float[]{m00, m01, m02, m10, m11, m12, 0.0f, 0.0f, 1.0f});
		m.setConcat(getMatrix(), m);
		int selStart = imeSelectionStart(nhandle);
		int selEnd = imeSelectionEnd(nhandle);
		int compStart = imeComposingStart(nhandle);
		int compEnd = imeComposingEnd(nhandle);
		Snippet snip = getSnippet();
		String composing = "";
		if (compStart != -1) {
			composing = snip.substringRunes(compStart, compEnd);
		}
		CursorAnchorInfo inf = new CursorAnchorInfo.Builder()
			.setMatrix(m)
			.setComposingText(imeToUTF16(nhandle, compStart), composing)
			.setSelectionRange(imeToUTF16(nhandle, selStart), imeToUTF16(nhandle, selEnd))
			.setInsertionMarkerLocation(caretX, caretTop, caretBase, caretBottom, 0)
			.build();
		imm.updateCursorAnchorInfo(this, inf);
	}

	static private native long onCreateView(GioView view);
	static private native void onDestroyView(long handle);
	static private native void onStartView(long handle);
	static private native void onStopView(long handle);
	static private native void onSurfaceDestroyed(long handle);
	static private native void onSurfaceChanged(long handle, Surface surface);
	static private native void onConfigurationChanged(long handle);
	static private native void onWindowInsets(long handle, int top, int right, int bottom, int left);
	static public native void onLowMemory();
	static private native void onTouchEvent(long handle, int action, int pointerID, int tool, float x, float y, float scrollX, float scrollY, int buttons, long time);
	static private native void onKeyEvent(long handle, int code, int character, boolean pressed, long time);
	static private native void onFrameCallback(long handle);
	static private native boolean onBack(long handle);
	static private native void onFocusChange(long handle, boolean focus);
	static private native AccessibilityNodeInfo initializeAccessibilityNodeInfo(long handle, int viewId, int screenX, int screenY, AccessibilityNodeInfo info);
	static private native void onTouchExploration(long handle, float x, float y);
	static private native void onExitTouchExploration(long handle);
	static private native void onA11yFocus(long handle, int viewId);
	static private native void onClearA11yFocus(long handle, int viewId);
	static private native void imeSetSnippet(long handle, int start, int end);
	static private native String imeSnippet(long handle);
	static private native int imeSnippetStart(long handle);
	static private native int imeSelectionStart(long handle);
	static private native int imeSelectionEnd(long handle);
	static private native int imeComposingStart(long handle);
	static private native int imeComposingEnd(long handle);
	static private native int imeReplace(long handle, int start, int end, String text);
	static private native int imeSetSelection(long handle, int start, int end);
	static private native int imeSetComposingRegion(long handle, int start, int end);
	// imeToRunes converts the Java character index into runes (Java code points).
	static private native int imeToRunes(long handle, int chars);
	// imeToUTF16 converts the rune index into Java characters.
	static private native int imeToUTF16(long handle, int runes);

	private class GioInputConnection implements InputConnection {
		private int batchDepth;

		@Override public boolean beginBatchEdit() {
			batchDepth++;
			return true;
		}

		@Override public boolean endBatchEdit() {
			batchDepth--;
			return batchDepth > 0;
		}

		@Override public boolean clearMetaKeyStates(int states) {
			return false;
		}

		@Override public boolean commitCompletion(CompletionInfo text) {
			return false;
		}

		@Override public boolean commitCorrection(CorrectionInfo info) {
			return false;
		}

		@Override public boolean commitText(CharSequence text, int cursor) {
			setComposingText(text, cursor);
			return finishComposingText();
		}

		@Override public boolean deleteSurroundingText(int beforeChars, int afterChars) {
			// translate before and after to runes.
			int selStart = imeSelectionStart(nhandle);
			int selEnd = imeSelectionEnd(nhandle);
			int before = selStart - imeToRunes(nhandle, imeToUTF16(nhandle, selStart) - beforeChars);
			int after = selEnd - imeToRunes(nhandle, imeToUTF16(nhandle, selEnd) - afterChars);
			return deleteSurroundingTextInCodePoints(before, after);
		}

		@Override public boolean finishComposingText() {
			imeSetComposingRegion(nhandle, -1, -1);
			return true;
		}

		@Override public int getCursorCapsMode(int reqModes) {
			Snippet snip = getSnippet();
			int selStart = imeSelectionStart(nhandle);
			int off = imeToUTF16(nhandle, selStart - snip.offset);
			if (off < 0 || off > snip.snippet.length()) {
				return 0;
			}
			return TextUtils.getCapsMode(snip.snippet, off, reqModes);
		}

		@Override public ExtractedText getExtractedText(ExtractedTextRequest request, int flags) {
			return null;
		}

		@Override public CharSequence getSelectedText(int flags) {
			Snippet snip = getSnippet();
			int selStart = imeSelectionStart(nhandle);
			int selEnd = imeSelectionEnd(nhandle);
			String sub = snip.substringRunes(selStart, selEnd);
			return sub;
		}

		@Override public CharSequence getTextAfterCursor(int n, int flags) {
			Snippet snip = getSnippet();
			int selStart = imeSelectionStart(nhandle);
			int selEnd = imeSelectionEnd(nhandle);
			// n are in Java characters, but in worst case we'll just ask for more runes
			// than wanted.
			imeSetSnippet(nhandle, selStart - n, selEnd + n);
			int start = selEnd;
			int end = imeToRunes(nhandle, imeToUTF16(nhandle, selEnd) + n);
			String ret = snip.substringRunes(start, end);
			return ret;
		}

		@Override public CharSequence getTextBeforeCursor(int n, int flags) {
			Snippet snip = getSnippet();
			int selStart = imeSelectionStart(nhandle);
			int selEnd = imeSelectionEnd(nhandle);
			// n are in Java characters, but in worst case we'll just ask for more runes
			// than wanted.
			imeSetSnippet(nhandle, selStart - n, selEnd + n);
			int start = imeToRunes(nhandle, imeToUTF16(nhandle, selStart) - n);
			int end = selStart;
			String ret = snip.substringRunes(start, end);
			return ret;
		}

		@Override public boolean performContextMenuAction(int id) {
			return false;
		}

		@Override public boolean performEditorAction(int editorAction) {
			long eventTime = SystemClock.uptimeMillis();
			// Translate to enter key.
			onKeyEvent(nhandle, KeyEvent.KEYCODE_ENTER, '\n', true, eventTime);
			onKeyEvent(nhandle, KeyEvent.KEYCODE_ENTER, '\n', false, eventTime);
			return true;
		}

		@Override public boolean performPrivateCommand(String action, Bundle data) {
			return false;
		}

		@Override public boolean reportFullscreenMode(boolean enabled) {
			return false;
		}

		@Override public boolean sendKeyEvent(KeyEvent event) {
			boolean pressed = event.getAction() == KeyEvent.ACTION_DOWN;
			onKeyEvent(nhandle, event.getKeyCode(), event.getUnicodeChar(), pressed, event.getEventTime());
			return true;
		}

		@Override public boolean setComposingRegion(int startChars, int endChars) {
			int compStart = imeToRunes(nhandle, startChars);
			int compEnd = imeToRunes(nhandle, endChars);
			imeSetComposingRegion(nhandle, compStart, compEnd);
			return true;
		}

		@Override public boolean setComposingText(CharSequence text, int relCursor) {
			int start = imeComposingStart(nhandle);
			int end = imeComposingEnd(nhandle);
			if (start == -1 || end == -1) {
				start = imeSelectionStart(nhandle);
				end = imeSelectionEnd(nhandle);
			}
			String str = text.toString();
			imeReplace(nhandle, start, end, str);
			int cursor = start;
			int runes = str.codePointCount(0, str.length());
			if (relCursor > 0) {
				cursor += runes;
				relCursor--;
			}
			imeSetComposingRegion(nhandle, start, start + runes);

			// Move cursor.
			Snippet snip = getSnippet();
			cursor = imeToRunes(nhandle, imeToUTF16(nhandle, cursor) + relCursor);
			imeSetSelection(nhandle, cursor, cursor);
			return true;
		}

		@Override public boolean setSelection(int startChars, int endChars) {
			int start = imeToRunes(nhandle, startChars);
			int end = imeToRunes(nhandle, endChars);
			imeSetSelection(nhandle, start, end);
			return true;
		}

		/*@Override*/ public boolean requestCursorUpdates(int cursorUpdateMode) {
			// We always provide cursor updates.
			return true;
		}

		/*@Override*/ public void closeConnection() {
		}

		/*@Override*/ public Handler getHandler() {
			return null;
		}

		/*@Override*/ public boolean commitContent(InputContentInfo info, int flags, Bundle opts) {
			return false;
		}

		/*@Override*/ public boolean deleteSurroundingTextInCodePoints(int before, int after) {
			if (after > 0) {
				int selEnd = imeSelectionEnd(nhandle);
				imeReplace(nhandle, selEnd, selEnd + after, "");
			}
			if (before > 0) {
				int selStart = imeSelectionStart(nhandle);
				imeReplace(nhandle, selStart - before, selStart, "");
			}
			return true;
		}

		/*@Override*/ public SurroundingText getSurroundingText(int beforeChars, int afterChars, int flags) {
			Snippet snip = getSnippet();
			int selStart = imeSelectionStart(nhandle);
			int selEnd = imeSelectionEnd(nhandle);
			// Expanding in Java characters is ok.
			imeSetSnippet(nhandle, selStart - beforeChars, selEnd + afterChars);
			return new SurroundingText(snip.snippet, imeToUTF16(nhandle, selStart), imeToUTF16(nhandle, selEnd), imeToUTF16(nhandle, snip.offset));
		}
	}

	private Snippet getSnippet() {
		Snippet snip = new Snippet();
		snip.snippet = imeSnippet(nhandle);
		snip.offset = imeSnippetStart(nhandle);
		return snip;
	}

	// Snippet is like android.view.inputmethod.SurroundingText but available for Android < 31.
	private static class Snippet {
		String snippet;
		// offset of snippet into the entire editor content. It is in runes because we won't require
		// Gio editors to keep track of UTF-16 offsets. The distinction won't matter in practice because IMEs only
		// ever see snippets.
		int offset;

		// substringRunes returns the substring from start to end in runes. The resuls is
		// truncated to the snippet.
		String substringRunes(int start, int end) {
			start -= this.offset;
			end -= this.offset;
			int runes = snippet.codePointCount(0, snippet.length());
			if (start < 0) {
				start = 0;
			}
			if (end < 0) {
				end = 0;
			}
			if (start > runes) {
				start = runes;
			}
			if (end > runes) {
				end = runes;
			}
			return snippet.substring(
				snippet.offsetByCodePoints(0, start),
				snippet.offsetByCodePoints(0, end)
			);
		}
	}

	@Override public AccessibilityNodeProvider getAccessibilityNodeProvider() {
		return new AccessibilityNodeProvider() {
			private final int[] screenOff = new int[2];

			@Override public AccessibilityNodeInfo createAccessibilityNodeInfo(int viewId) {
				AccessibilityNodeInfo info = null;
				if (viewId == View.NO_ID) {
					info = AccessibilityNodeInfo.obtain(GioView.this);
					GioView.this.onInitializeAccessibilityNodeInfo(info);
				} else {
					info = AccessibilityNodeInfo.obtain(GioView.this, viewId);
					info.setPackageName(getContext().getPackageName());
					info.setVisibleToUser(true);
				}
				GioView.this.getLocationOnScreen(screenOff);
				info = GioView.this.initializeAccessibilityNodeInfo(nhandle, viewId, screenOff[0], screenOff[1], info);
				return info;
			}

			@Override public boolean performAction(int viewId, int action, Bundle arguments) {
				if (viewId == View.NO_ID) {
					return GioView.this.performAccessibilityAction(action, arguments);
				}
				switch (action) {
				case AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS:
					GioView.this.onA11yFocus(nhandle, viewId);
					GioView.this.sendA11yEvent(AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED, viewId);
					return true;
				case AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS:
					GioView.this.onClearA11yFocus(nhandle, viewId);
					GioView.this.sendA11yEvent(AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED, viewId);
					return true;
				}
				return false;
			}
		};
	}
}
