/*
  ==============================================================================

   This file is part of the JUCE library.
   Copyright (c) 2020 - Raw Material Software Limited

   JUCE is an open source library subject to commercial or open-source
   licensing.

   By using JUCE, you agree to the terms of both the JUCE 6 End-User License
   Agreement and JUCE Privacy Policy (both effective as of the 16th June 2020).

   End User License Agreement: www.juce.com/juce-6-licence
   Privacy Policy: www.juce.com/juce-privacy-policy

   Or: You may also use this code under the terms of the GPL v3 (see
   www.gnu.org/licenses).

   JUCE IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER
   EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE
   DISCLAIMED.

  ==============================================================================
*/

namespace juce
{

#define JUCE_NATIVE_ACCESSIBILITY_INCLUDED 1

#define JNI_CLASS_MEMBERS(METHOD, STATICMETHOD, FIELD, STATICFIELD, CALLBACK) \
 METHOD (constructor,              "<init>",                   "()V") \
 METHOD (setSource,                "setSource",                "(Landroid/view/View;I)V") \
 METHOD (addChild,                 "addChild",                 "(Landroid/view/View;I)V") \
 METHOD (setParent,                "setParent",                "(Landroid/view/View;)V") \
 METHOD (setVirtualParent,         "setParent",                "(Landroid/view/View;I)V") \
 METHOD (setBoundsInScreen,        "setBoundsInScreen",        "(Landroid/graphics/Rect;)V") \
 METHOD (setBoundsInParent,        "setBoundsInParent",        "(Landroid/graphics/Rect;)V") \
 METHOD (setPackageName,           "setPackageName",           "(Ljava/lang/CharSequence;)V") \
 METHOD (setClassName,             "setClassName",             "(Ljava/lang/CharSequence;)V") \
 METHOD (setContentDescription,    "setContentDescription",    "(Ljava/lang/CharSequence;)V") \
 METHOD (setCheckable,             "setCheckable",             "(Z)V") \
 METHOD (setChecked,               "setChecked",               "(Z)V") \
 METHOD (setClickable,             "setClickable",             "(Z)V") \
 METHOD (setEnabled,               "setEnabled",               "(Z)V") \
 METHOD (setFocusable,             "setFocusable",             "(Z)V") \
 METHOD (setFocused,               "setFocused",               "(Z)V") \
 METHOD (setPassword,              "setPassword",              "(Z)V") \
 METHOD (setSelected,              "setSelected",              "(Z)V") \
 METHOD (setVisibleToUser,         "setVisibleToUser",         "(Z)V") \
 METHOD (setAccessibilityFocused,  "setAccessibilityFocused",  "(Z)V") \
 METHOD (setText,                  "setText",                  "(Ljava/lang/CharSequence;)V") \
 METHOD (setMovementGranularities, "setMovementGranularities", "(I)V") \
 METHOD (addAction,                "addAction",                "(I)V") \

 DECLARE_JNI_CLASS (AndroidAccessibilityNodeInfo, "android/view/accessibility/AccessibilityNodeInfo")
#undef JNI_CLASS_MEMBERS

#define JNI_CLASS_MEMBERS(METHOD, STATICMETHOD, FIELD, STATICFIELD, CALLBACK) \
 STATICMETHOD (obtain, "obtain", "(I)Landroid/view/accessibility/AccessibilityEvent;") \
 METHOD (setPackageName, "setPackageName", "(Ljava/lang/CharSequence;)V") \
 METHOD (setSource,      "setSource",      "(Landroid/view/View;I)V") \

 DECLARE_JNI_CLASS (AndroidAccessibilityEvent, "android/view/accessibility/AccessibilityEvent")
#undef JNI_CLASS_MEMBERS

#define JNI_CLASS_MEMBERS(METHOD, STATICMETHOD, FIELD, STATICFIELD, CALLBACK) \
 METHOD (isEnabled, "isEnabled", "()Z") \

 DECLARE_JNI_CLASS (AndroidAccessibilityManager, "android/view/accessibility/AccessibilityManager")
#undef JNI_CLASS_MEMBERS

namespace
{
    constexpr int HOST_VIEW_ID = -1;

    constexpr int TYPE_VIEW_CLICKED                     = 0x00000001,
                  TYPE_VIEW_SELECTED                    = 0x00000004,
                  TYPE_VIEW_ACCESSIBILITY_FOCUSED       = 0x00008000,
                  TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED = 0x00010000,
                  TYPE_WINDOW_CONTENT_CHANGED           = 0x00000800,
                  TYPE_VIEW_TEXT_SELECTION_CHANGED      = 0x00002000,
                  TYPE_VIEW_TEXT_CHANGED                = 0x00000010;

    constexpr int CONTENT_CHANGE_TYPE_SUBTREE             = 0x00000001,
                  CONTENT_CHANGE_TYPE_CONTENT_DESCRIPTION = 0x00000004;

    constexpr int ACTION_ACCESSIBILITY_FOCUS              = 0x00000040,
                  ACTION_CLEAR_ACCESSIBILITY_FOCUS        = 0x00000080,
                  ACTION_CLEAR_FOCUS                      = 0x00000002,
                  ACTION_CLEAR_SELECTION                  = 0x00000008,
                  ACTION_CLICK                            = 0x00000010,
                  ACTION_COLLAPSE                         = 0x00080000,
                  ACTION_EXPAND                           = 0x00040000,
                  ACTION_FOCUS                            = 0x00000001,
                  ACTION_NEXT_AT_MOVEMENT_GRANULARITY     = 0x00000100,
                  ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY = 0x00000200,
                  ACTION_SCROLL_BACKWARD                  = 0x00002000,
                  ACTION_SCROLL_FORWARD                   = 0x00001000,
                  ACTION_SELECT                           = 0x00000004,
                  ACTION_SET_SELECTION                    = 0x00020000,
                  ACTION_SET_TEXT                         = 0x00200000;

    constexpr int MOVEMENT_GRANULARITY_CHARACTER = 0x00000001,
                  MOVEMENT_GRANULARITY_LINE      = 0x00000004,
                  MOVEMENT_GRANULARITY_PAGE      = 0x00000010,
                  MOVEMENT_GRANULARITY_PARAGRAPH = 0x00000008,
                  MOVEMENT_GRANULARITY_WORD      = 0x00000002,
                  ALL_GRANULARITIES = MOVEMENT_GRANULARITY_CHARACTER
                                    | MOVEMENT_GRANULARITY_LINE
                                    | MOVEMENT_GRANULARITY_PAGE
                                    | MOVEMENT_GRANULARITY_PARAGRAPH
                                    | MOVEMENT_GRANULARITY_WORD;

    constexpr int ACCESSIBILITY_LIVE_REGION_POLITE = 0x00000001;
}

static jmethodID nodeInfoSetEditable                     = nullptr;
static jmethodID nodeInfoSetTextSelection                = nullptr;
static jmethodID nodeInfoSetLiveRegion                   = nullptr;
static jmethodID accessibilityEventSetContentChangeTypes = nullptr;

static void loadSDKDependentMethods()
{
    static bool hasChecked = false;

    if (! hasChecked)
    {
        hasChecked = true;

        auto* env = getEnv();
        const auto sdkVersion = getAndroidSDKVersion();

        if (sdkVersion >= 18)
        {
            nodeInfoSetEditable      = env->GetMethodID (AndroidAccessibilityNodeInfo, "setEditable",      "(Z)V");
            nodeInfoSetTextSelection = env->GetMethodID (AndroidAccessibilityNodeInfo, "setTextSelection", "(II)V");
        }

        if (sdkVersion >= 19)
        {
            nodeInfoSetLiveRegion                   = env->GetMethodID (AndroidAccessibilityNodeInfo, "setLiveRegion",         "(I)V");
            accessibilityEventSetContentChangeTypes = env->GetMethodID (AndroidAccessibilityEvent,    "setContentChangeTypes", "(I)V");
        }
    }
}

static constexpr auto getClassName (AccessibilityRole role)
{
    switch (role)
    {
        case AccessibilityRole::editableText:  return "android.widget.EditText";
        case AccessibilityRole::toggleButton:  return "android.widget.CheckBox";
        case AccessibilityRole::radioButton:   return "android.widget.RadioButton";
        case AccessibilityRole::image:         return "android.widget.ImageView";
        case AccessibilityRole::popupMenu:     return "android.widget.PopupMenu";
        case AccessibilityRole::comboBox:      return "android.widget.Spinner";
        case AccessibilityRole::tree:          return "android.widget.ExpandableListView";
        case AccessibilityRole::list:          return "android.widget.ListView";
        case AccessibilityRole::table:         return "android.widget.TableLayout";
        case AccessibilityRole::progressBar:   return "android.widget.ProgressBar";

        case AccessibilityRole::scrollBar:
        case AccessibilityRole::slider:        return "android.widget.SeekBar";

        case AccessibilityRole::hyperlink:
        case AccessibilityRole::button:        return "android.widget.Button";

        case AccessibilityRole::label:
        case AccessibilityRole::staticText:    return "android.widget.TextView";

        case AccessibilityRole::tooltip:
        case AccessibilityRole::splashScreen:
        case AccessibilityRole::dialogWindow:  return "android.widget.PopupWindow";

        case AccessibilityRole::column:
        case AccessibilityRole::row:
        case AccessibilityRole::cell:
        case AccessibilityRole::menuItem:
        case AccessibilityRole::menuBar:
        case AccessibilityRole::listItem:
        case AccessibilityRole::treeItem:
        case AccessibilityRole::window:
        case AccessibilityRole::tableHeader:
        case AccessibilityRole::unspecified:
        case AccessibilityRole::group:
        case AccessibilityRole::ignored:       break;
    }

    return "android.view.View";
}

static jobject getSourceView (const AccessibilityHandler& handler)
{
    if (auto* peer = handler.getComponent().getPeer())
        return (jobject) peer->getNativeHandle();

    return nullptr;
}

void sendAccessibilityEventImpl (const AccessibilityHandler& handler, int eventType, int contentChangeTypes);

//==============================================================================
class AccessibilityNativeHandle
{
public:
    static AccessibilityHandler* getAccessibilityHandlerForVirtualViewId (int virtualViewId)
    {
        auto iter = virtualViewIdMap.find (virtualViewId);

        if (iter != virtualViewIdMap.end())
            return iter->second;

        return nullptr;
    }

    explicit AccessibilityNativeHandle (AccessibilityHandler& h)
        : accessibilityHandler (h),
          virtualViewId (getVirtualViewIdForHandler (accessibilityHandler))
    {
        loadSDKDependentMethods();

        if (virtualViewId != HOST_VIEW_ID)
            virtualViewIdMap[virtualViewId] = &accessibilityHandler;
    }

    ~AccessibilityNativeHandle()
    {
        if (virtualViewId != HOST_VIEW_ID)
            virtualViewIdMap.erase (virtualViewId);
    }

    int getVirtualViewId() const noexcept  { return virtualViewId; }

    void populateNodeInfo (jobject info)
    {
        const ScopedValueSetter<bool> svs (inPopulateNodeInfo, true);

        const auto sourceView = getSourceView (accessibilityHandler);

        if (sourceView == nullptr)
            return;

        auto* env = getEnv();
        auto appContext = getAppContext();

        if (appContext.get() == nullptr)
            return;

        {
            for (auto* child : accessibilityHandler.getChildren())
                env->CallVoidMethod (info, AndroidAccessibilityNodeInfo.addChild,
                                     sourceView, child->getNativeImplementation()->getVirtualViewId());

            if (auto* parent = accessibilityHandler.getParent())
                env->CallVoidMethod (info, AndroidAccessibilityNodeInfo.setVirtualParent,
                                     sourceView, parent->getNativeImplementation()->getVirtualViewId());
            else
                env->CallVoidMethod (info, AndroidAccessibilityNodeInfo.setParent, sourceView);
        }

        {
            const auto scale = Desktop::getInstance().getDisplays().getPrimaryDisplay()->scale;

            const auto screenBounds = accessibilityHandler.getComponent().getScreenBounds() * scale;

            LocalRef<jobject> rect (env->NewObject (AndroidRect, AndroidRect.constructor,
                                                    screenBounds.getX(),     screenBounds.getY(),
                                                    screenBounds.getRight(), screenBounds.getBottom()));

            env->CallVoidMethod (info, AndroidAccessibilityNodeInfo.setBoundsInScreen, rect.get());

            const auto boundsInParent = accessibilityHandler.getComponent().getBoundsInParent() * scale;

            rect = LocalRef<jobject> (env->NewObject (AndroidRect, AndroidRect.constructor,
                                                      boundsInParent.getX(),     boundsInParent.getY(),
                                                      boundsInParent.getRight(), boundsInParent.getBottom()));

            env->CallVoidMethod (info, AndroidAccessibilityNodeInfo.setBoundsInParent, rect.get());
        }

        const auto state = accessibilityHandler.getCurrentState();

        env->CallVoidMethod (info,
                             AndroidAccessibilityNodeInfo.setEnabled,
                             ! state.isIgnored());
        env->CallVoidMethod (info,
                             AndroidAccessibilityNodeInfo.setVisibleToUser,
                             true);
        env->CallVoidMethod (info,
                             AndroidAccessibilityNodeInfo.setPackageName,
                             env->CallObjectMethod (appContext.get(),
                                                    AndroidContext.getPackageName));
        env->CallVoidMethod (info,
                             AndroidAccessibilityNodeInfo.setSource,
                             sourceView,
                             virtualViewId);
        env->CallVoidMethod (info,
                             AndroidAccessibilityNodeInfo.setClassName,
                             javaString (getClassName (accessibilityHandler.getRole())).get());
        env->CallVoidMethod (info,
                             AndroidAccessibilityNodeInfo.setContentDescription,
                             getDescriptionString().get());

        if (state.isFocusable())
        {
            env->CallVoidMethod (info, AndroidAccessibilityNodeInfo.setFocusable, true);

            const auto& component = accessibilityHandler.getComponent();

            if (component.getWantsKeyboardFocus())
            {
                const auto hasKeyboardFocus = component.hasKeyboardFocus (false);

                env->CallVoidMethod (info,
                                     AndroidAccessibilityNodeInfo.setFocused,
                                     hasKeyboardFocus);
                env->CallVoidMethod (info,
                                     AndroidAccessibilityNodeInfo.addAction,
                                     hasKeyboardFocus ? ACTION_CLEAR_FOCUS : ACTION_FOCUS);
            }

            const auto isAccessibleFocused = accessibilityHandler.hasFocus (false);

            env->CallVoidMethod (info,
                                 AndroidAccessibilityNodeInfo.setAccessibilityFocused,
                                 isAccessibleFocused);

            env->CallVoidMethod (info,
                                 AndroidAccessibilityNodeInfo.addAction,
                                 isAccessibleFocused ? ACTION_CLEAR_ACCESSIBILITY_FOCUS
                                                     : ACTION_ACCESSIBILITY_FOCUS);
        }

        if (state.isCheckable())
        {
            env->CallVoidMethod (info,
                                 AndroidAccessibilityNodeInfo.setCheckable,
                                 true);
            env->CallVoidMethod (info,
                                 AndroidAccessibilityNodeInfo.setChecked,
                                 state.isChecked());
        }

        if (state.isSelectable() || state.isMultiSelectable())
        {
            const auto isSelected = state.isSelected();

            env->CallVoidMethod (info,
                                 AndroidAccessibilityNodeInfo.setSelected,
                                 isSelected);
            env->CallVoidMethod (info,
                                 AndroidAccessibilityNodeInfo.addAction,
                                 isSelected ? ACTION_CLEAR_SELECTION : ACTION_SELECT);
        }

        if (accessibilityHandler.getActions().contains (AccessibilityActionType::press))
        {
            env->CallVoidMethod (info,
                                 AndroidAccessibilityNodeInfo.setClickable,
                                 true);
            env->CallVoidMethod (info,
                                 AndroidAccessibilityNodeInfo.addAction,
                                 ACTION_CLICK);
        }

        if (accessibilityHandler.getActions().contains (AccessibilityActionType::showMenu)
            && state.isExpandable())
        {
            env->CallVoidMethod (info,
                                 AndroidAccessibilityNodeInfo.addAction,
                                 state.isExpanded() ? ACTION_COLLAPSE : ACTION_EXPAND);
        }

        if (auto* textInterface = accessibilityHandler.getTextInterface())
        {
            env->CallVoidMethod (info,
                                 AndroidAccessibilityNodeInfo.setText,
                                 javaString (textInterface->getText ({ 0, textInterface->getTotalNumCharacters() })).get());

            const auto isReadOnly = textInterface->isReadOnly();

            env->CallVoidMethod (info,
                                 AndroidAccessibilityNodeInfo.setPassword,
                                 textInterface->isDisplayingProtectedText());

            if (nodeInfoSetEditable != nullptr)
                env->CallVoidMethod (info, nodeInfoSetEditable, ! isReadOnly);

            const auto selection = textInterface->getSelection();

            if (nodeInfoSetTextSelection != nullptr && ! selection.isEmpty())
                env->CallVoidMethod (info,
                                     nodeInfoSetTextSelection,
                                     selection.getStart(), selection.getEnd());

            if (nodeInfoSetLiveRegion != nullptr && accessibilityHandler.hasFocus (false))
                env->CallVoidMethod (info,
                                     nodeInfoSetLiveRegion,
                                     ACCESSIBILITY_LIVE_REGION_POLITE);

            env->CallVoidMethod (info,
                                 AndroidAccessibilityNodeInfo.setMovementGranularities,
                                 ALL_GRANULARITIES);

            env->CallVoidMethod (info,
                                 AndroidAccessibilityNodeInfo.addAction,
                                 ACTION_NEXT_AT_MOVEMENT_GRANULARITY);
            env->CallVoidMethod (info,
                                 AndroidAccessibilityNodeInfo.addAction,
                                 ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY);
            env->CallVoidMethod (info,
                                 AndroidAccessibilityNodeInfo.addAction,
                                 ACTION_SET_SELECTION);

            if (! isReadOnly)
                env->CallVoidMethod (info, AndroidAccessibilityNodeInfo.addAction, ACTION_SET_TEXT);
        }

        if (auto* valueInterface = accessibilityHandler.getValueInterface())
        {
            if (! valueInterface->isReadOnly())
            {
                const auto range = valueInterface->getRange();

                if (range.isValid())
                {
                    env->CallVoidMethod (info,
                                         AndroidAccessibilityNodeInfo.addAction,
                                         ACTION_SCROLL_FORWARD);
                    env->CallVoidMethod (info,
                                         AndroidAccessibilityNodeInfo.addAction,
                                         ACTION_SCROLL_BACKWARD);
                }
            }
        }
    }

    bool performAction (int action, jobject arguments)
    {
        switch (action)
        {
            case ACTION_ACCESSIBILITY_FOCUS:
            {
                const WeakReference<Component> safeComponent (&accessibilityHandler.getComponent());

                accessibilityHandler.getActions().invoke (AccessibilityActionType::focus);

                if (safeComponent != nullptr)
                    accessibilityHandler.grabFocus();

                return true;
            }

            case ACTION_CLEAR_ACCESSIBILITY_FOCUS:
            {
                accessibilityHandler.giveAwayFocus();
                return true;
            }

            case ACTION_FOCUS:
            case ACTION_CLEAR_FOCUS:
            {
                auto& component = accessibilityHandler.getComponent();

                if (component.getWantsKeyboardFocus())
                {
                    const auto hasFocus = component.hasKeyboardFocus (false);

                    if (hasFocus && action == ACTION_CLEAR_FOCUS)
                        component.giveAwayKeyboardFocus();
                    else if (! hasFocus && action == ACTION_FOCUS)
                        component.grabKeyboardFocus();

                    return true;
                }

                break;
            }

            case ACTION_CLICK:
            {
                if (accessibilityHandler.getActions().invoke (AccessibilityActionType::press))
                {
                    sendAccessibilityEventImpl (accessibilityHandler, TYPE_VIEW_CLICKED, 0);
                    return true;
                }

                break;
            }

            case ACTION_SELECT:
            case ACTION_CLEAR_SELECTION:
            {
                const auto state = accessibilityHandler.getCurrentState();

                if (state.isSelectable() || state.isMultiSelectable())
                {
                    const auto isSelected = state.isSelected();

                    if ((isSelected && action == ACTION_CLEAR_SELECTION)
                        || (! isSelected && action == ACTION_SELECT))
                    {
                        return accessibilityHandler.getActions().invoke (AccessibilityActionType::toggle);
                    }

                }

                break;
            }

            case ACTION_EXPAND:
            case ACTION_COLLAPSE:
            {
                const auto state = accessibilityHandler.getCurrentState();

                if (state.isExpandable())
                {
                    const auto isExpanded = state.isExpanded();

                    if ((isExpanded && action == ACTION_COLLAPSE)
                        || (! isExpanded && action == ACTION_EXPAND))
                    {
                        return accessibilityHandler.getActions().invoke (AccessibilityActionType::showMenu);
                    }
                }

                break;
            }

            case ACTION_NEXT_AT_MOVEMENT_GRANULARITY:      return moveCursor (arguments, true);
            case ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY:  return moveCursor (arguments, false);

            case ACTION_SET_SELECTION:
            {
                if (auto* textInterface = accessibilityHandler.getTextInterface())
                {
                    auto* env = getEnv();

                    const auto selection = [&]() -> Range<int>
                    {
                        const auto selectionStartKey = javaString ("ACTION_ARGUMENT_SELECTION_START_INT");
                        const auto selectionEndKey   = javaString ("ACTION_ARGUMENT_SELECTION_END_INT");

                        const auto hasKey = [&env, &arguments] (const auto& key)
                        {
                            return env->CallBooleanMethod (arguments, AndroidBundle.containsKey, key.get());
                        };

                        if (hasKey (selectionStartKey) && hasKey (selectionEndKey))
                        {
                            const auto getKey = [&env, &arguments] (const auto& key)
                            {
                                return env->CallIntMethod (arguments, AndroidBundle.getInt, key.get());
                            };

                            return { getKey (selectionStartKey), getKey (selectionEndKey) };
                        }

                        return {};
                    }();

                    textInterface->setSelection (selection);

                    return true;
                }

                break;
            }

            case ACTION_SET_TEXT:
            {
                if (auto* textInterface = accessibilityHandler.getTextInterface())
                {
                    if (! textInterface->isReadOnly())
                    {
                        const auto charSequenceKey = javaString ("ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE");

                        auto* env = getEnv();

                        const auto text = [&]() -> String
                        {
                            if (env->CallBooleanMethod (arguments, AndroidBundle.containsKey, charSequenceKey.get()))
                            {
                                LocalRef<jobject> charSequence (env->CallObjectMethod (arguments,
                                                                                       AndroidBundle.getCharSequence,
                                                                                       charSequenceKey.get()));
                                LocalRef<jstring> textStringRef ((jstring) env->CallObjectMethod (charSequence,
                                                                                                  JavaCharSequence.toString));

                                return juceString (textStringRef.get());
                            }

                            return {};
                        }();

                        textInterface->setText (text);
                    }
                }

                break;
            }

            case ACTION_SCROLL_BACKWARD:
            case ACTION_SCROLL_FORWARD:
            {
                if (auto* valueInterface = accessibilityHandler.getValueInterface())
                {
                    if (! valueInterface->isReadOnly())
                    {
                        const auto range = valueInterface->getRange();

                        if (range.isValid())
                        {
                            const auto interval = action == ACTION_SCROLL_BACKWARD ? -range.getInterval()
                                                                                   : range.getInterval();
                            valueInterface->setValue (jlimit (range.getMinimumValue(),
                                                              range.getMaximumValue(),
                                                              valueInterface->getCurrentValue() + interval));

                            // required for Android to announce the new value
                            sendAccessibilityEventImpl (accessibilityHandler, TYPE_VIEW_SELECTED, 0);
                            return true;
                        }
                    }
                }

                break;
            }
        }

        return false;
    }

    bool isInPopulateNodeInfo() const noexcept  { return inPopulateNodeInfo; }

private:
    static std::unordered_map<int, AccessibilityHandler*> virtualViewIdMap;

    static int getVirtualViewIdForHandler (const AccessibilityHandler& handler)
    {
        static int counter = 0;

        if (handler.getComponent().isOnDesktop())
            return HOST_VIEW_ID;

        return counter++;
    }

    LocalRef<jstring> getDescriptionString() const
    {
        const auto valueString = [this]() -> String
        {
            if (auto* textInterface = accessibilityHandler.getTextInterface())
                return textInterface->getText ({ 0, textInterface->getTotalNumCharacters() });

            if (auto* valueInterface = accessibilityHandler.getValueInterface())
                return valueInterface->getCurrentValueAsString();

            return {};
        }();

        StringArray strings (accessibilityHandler.getTitle(),
                             valueString,
                             accessibilityHandler.getDescription(),
                             accessibilityHandler.getHelp());

        strings.removeEmptyStrings();

        return javaString (strings.joinIntoString (","));
    }

    bool moveCursor (jobject arguments, bool forwards)
    {
        if (auto* textInterface = accessibilityHandler.getTextInterface())
        {
            const auto granularityKey = javaString ("ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT");
            const auto extendSelectionKey = javaString ("ACTION_ARGUMENT_EXTEND_SELECTION_BOOLEAN");

            auto* env = getEnv();

            const auto boundaryType = [&]
            {
                const auto granularity = env->CallIntMethod (arguments,
                                                             AndroidBundle.getInt,
                                                             granularityKey.get());

                using BoundaryType = AccessibilityTextHelpers::BoundaryType;

                switch (granularity)
                {
                    case MOVEMENT_GRANULARITY_CHARACTER:  return BoundaryType::character;
                    case MOVEMENT_GRANULARITY_WORD:       return BoundaryType::word;
                    case MOVEMENT_GRANULARITY_LINE:       return BoundaryType::line;
                    case MOVEMENT_GRANULARITY_PARAGRAPH:
                    case MOVEMENT_GRANULARITY_PAGE:       return BoundaryType::document;
                }

                jassertfalse;
                return BoundaryType::character;
            }();

            using Direction = AccessibilityTextHelpers::Direction;

            const auto cursorPos = AccessibilityTextHelpers::findTextBoundary (*textInterface,
                                                                               textInterface->getTextInsertionOffset(),
                                                                               boundaryType,
                                                                               forwards ? Direction::forwards
                                                                                        : Direction::backwards);

            const auto newSelection = [&]() -> Range<int>
            {
                const auto currentSelection = textInterface->getSelection();
                const auto extendSelection = env->CallBooleanMethod (arguments,
                                                                     AndroidBundle.getBoolean,
                                                                     extendSelectionKey.get());

                if (! extendSelection)
                    return { cursorPos, cursorPos };

                const auto start = currentSelection.getStart();
                const auto end = currentSelection.getEnd();

                if (forwards)
                    return { start, jmax (start, cursorPos) };

                return { jmin (start, cursorPos), end };
            }();

            textInterface->setSelection (newSelection);
            return true;
        }

        return false;
    }

    AccessibilityHandler& accessibilityHandler;
    const int virtualViewId;
    bool inPopulateNodeInfo = false;

    //==============================================================================
    JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (AccessibilityNativeHandle)
};

std::unordered_map<int, AccessibilityHandler*> AccessibilityNativeHandle::virtualViewIdMap;

class AccessibilityHandler::AccessibilityNativeImpl : public AccessibilityNativeHandle
{
public:
    using AccessibilityNativeHandle::AccessibilityNativeHandle;
};

//==============================================================================
AccessibilityNativeHandle* AccessibilityHandler::getNativeImplementation() const
{
    return nativeImpl.get();
}

static bool areAnyAccessibilityClientsActive()
{
    auto* env = getEnv();
    auto appContext = getAppContext();

    if (appContext.get() != nullptr)
    {
        LocalRef<jobject> accessibilityManager (env->CallObjectMethod (appContext.get(), AndroidContext.getSystemService,
                                                                       javaString ("accessibility").get()));

        if (accessibilityManager != nullptr)
            return env->CallBooleanMethod (accessibilityManager.get(), AndroidAccessibilityManager.isEnabled);
    }

    return false;
}

void sendAccessibilityEventImpl (const AccessibilityHandler& handler, int eventType, int contentChangeTypes)
{
    if (! areAnyAccessibilityClientsActive())
        return;

    if (const auto sourceView = getSourceView (handler))
    {
        const auto* nativeImpl = handler.getNativeImplementation();

        if (nativeImpl == nullptr || nativeImpl->isInPopulateNodeInfo())
            return;

        auto* env = getEnv();
        auto appContext = getAppContext();

        if (appContext.get() == nullptr)
            return;

        LocalRef<jobject> event (env->CallStaticObjectMethod (AndroidAccessibilityEvent,
                                                              AndroidAccessibilityEvent.obtain,
                                                              eventType));

        env->CallVoidMethod (event,
                             AndroidAccessibilityEvent.setPackageName,
                             env->CallObjectMethod (appContext.get(),
                                                    AndroidContext.getPackageName));

        env->CallVoidMethod (event,
                             AndroidAccessibilityEvent.setSource,
                             sourceView,
                             nativeImpl->getVirtualViewId());

        if (contentChangeTypes != 0 && accessibilityEventSetContentChangeTypes != nullptr)
            env->CallVoidMethod (event,
                                 accessibilityEventSetContentChangeTypes,
                                 contentChangeTypes);

        env->CallBooleanMethod (sourceView,
                                AndroidViewGroup.requestSendAccessibilityEvent,
                                sourceView,
                                event.get());
    }
}

void notifyAccessibilityEventInternal (const AccessibilityHandler& handler,
                                       InternalAccessibilityEvent eventType)
{
    if (eventType == InternalAccessibilityEvent::elementCreated
        || eventType == InternalAccessibilityEvent::elementDestroyed
        || eventType == InternalAccessibilityEvent::elementMovedOrResized)
    {
        if (auto* parent = handler.getParent())
            sendAccessibilityEventImpl (*parent, TYPE_WINDOW_CONTENT_CHANGED, CONTENT_CHANGE_TYPE_SUBTREE);

        return;
    }

    auto notification = [&handler, eventType]
    {
        switch (eventType)
        {
            case InternalAccessibilityEvent::focusChanged:
                return handler.hasFocus (false) ? TYPE_VIEW_ACCESSIBILITY_FOCUSED
                                                : TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED;

            case InternalAccessibilityEvent::elementCreated:
            case InternalAccessibilityEvent::elementDestroyed:
            case InternalAccessibilityEvent::elementMovedOrResized:
            case InternalAccessibilityEvent::windowOpened:
            case InternalAccessibilityEvent::windowClosed:
                break;
        }

        return 0;
    }();

    if (notification != 0)
        sendAccessibilityEventImpl (handler, notification, 0);
}

void AccessibilityHandler::notifyAccessibilityEvent (AccessibilityEvent eventType) const
{
    auto notification = [eventType]
    {
        switch (eventType)
        {
            case AccessibilityEvent::textSelectionChanged:  return TYPE_VIEW_TEXT_SELECTION_CHANGED;
            case AccessibilityEvent::textChanged:           return TYPE_VIEW_TEXT_CHANGED;

            case AccessibilityEvent::titleChanged:
            case AccessibilityEvent::structureChanged:      return TYPE_WINDOW_CONTENT_CHANGED;

            case AccessibilityEvent::rowSelectionChanged:
            case AccessibilityEvent::valueChanged:          break;
        }

        return 0;
    }();

    if (notification == 0)
        return;

    const auto contentChangeTypes = [eventType]
    {
        if (eventType == AccessibilityEvent::titleChanged)      return CONTENT_CHANGE_TYPE_CONTENT_DESCRIPTION;
        if (eventType == AccessibilityEvent::structureChanged)  return CONTENT_CHANGE_TYPE_SUBTREE;

        return 0;
    }();

    sendAccessibilityEventImpl (*this, notification, contentChangeTypes);
}

void AccessibilityHandler::postAnnouncement (const String& announcementString,
                                             AnnouncementPriority)
{
    if (! areAnyAccessibilityClientsActive())
        return;

    const auto rootView = []
    {
        LocalRef<jobject> activity (getMainActivity());

        if (activity != nullptr)
        {
            auto* env = getEnv();

            LocalRef<jobject> mainWindow (env->CallObjectMethod (activity.get(), AndroidActivity.getWindow));
            LocalRef<jobject> decorView (env->CallObjectMethod (mainWindow.get(), AndroidWindow.getDecorView));

            return LocalRef<jobject> (env->CallObjectMethod (decorView.get(), AndroidView.getRootView));
        }

        return LocalRef<jobject>();
    }();

    if (rootView != nullptr)
        getEnv()->CallVoidMethod (rootView.get(),
                                  AndroidView.announceForAccessibility,
                                  javaString (announcementString).get());
}

} // namespace juce
