BR: Safe area insets and Bluetooth pairing dialogue

Hi there. Here is a little demo app which shows how displaying the MIDI Bluetooth pairing dialogue breaks the safe area insets / kiosk mode on Android. Toggling between normal and kiosk mode works fine until the dialogue is displayed.

Note that not only the Bluetooth dialogue has this effect, but likewise other windows which are placed on the desktop in a similar manner. Also, the pairing dialogue is not sized correctly when the app is in pop-up mode.

(Very sorry to continue to torture you with this, it must be so frustrating!)


#include <JuceHeader.h>

class MainComponent  : public juce::Component
{
public:
    MainComponent()
    {
        addAndMakeVisible (kioskButton);
        kioskButton.setButtonText ("Toggle kiosk mode");
        kioskButton.onClick = [this] { toggleKioskMode(); };

        addAndMakeVisible (styleButton);
        styleButton.setButtonText ("Toggle app style");
        styleButton.onClick = [this] { toggleAppStyle(); };

        addAndMakeVisible (bluetoothButton);
        bluetoothButton.setButtonText ("Bluetooth MIDI pairing dialog");
        bluetoothButton.onClick = [this]
        {
            if (juce::BluetoothMidiDevicePairingDialogue::isAvailable())
                openBluetoothDialogue();
        };
    }
    
    void openBluetoothDialogue()
    {
        if (juce::RuntimePermissions::isGranted (juce::RuntimePermissions::bluetoothMidi))
        {
            juce::BluetoothMidiDevicePairingDialogue::open();
        }
        else
        {
            juce::RuntimePermissions::request (juce::RuntimePermissions::bluetoothMidi, [&] (auto)
            {
                if (juce::RuntimePermissions::isGranted (juce::RuntimePermissions::bluetoothMidi))
                    juce::BluetoothMidiDevicePairingDialogue::open();
            });
        }
    }

    void resized() override
    {
        auto area = getSafeBounds();

        const int buttonHeight = 40;
        const int spacing = 10;
        const int totalHeight = 3 * buttonHeight + 2 * spacing;

        auto y = (area.getHeight() - totalHeight) / 2;
        auto buttonArea = area.withTrimmedTop (y).removeFromTop (totalHeight).reduced (20, 0);

        kioskButton.setBounds (buttonArea.removeFromTop (buttonHeight));
        
        buttonArea.removeFromTop (spacing);
        styleButton.setBounds (buttonArea.removeFromTop (buttonHeight));
        
        buttonArea.removeFromTop (spacing);
        bluetoothButton.setBounds (buttonArea.removeFromTop (buttonHeight));
        
        repaint();
    }

    juce::Rectangle<int> getSafeBounds()
    {
        auto* display = juce::Desktop::getInstance().getDisplays().getDisplayForRect (getScreenBounds());
        auto safeBounds = getLocalBounds();

       #if JUCE_IOS || JUCE_ANDROID
        display->keyboardInsets.subtractFrom (safeBounds);
        display->safeAreaInsets.subtractFrom (safeBounds);
       #endif
       
       return safeBounds;
    }

    void paint (juce::Graphics& g) override
    {
        auto* display = juce::Desktop::getInstance().getDisplays().getDisplayForRect (getScreenBounds());
        auto safeBounds = getSafeBounds();

        juce::String displayInfo ("totalArea: " + display->totalArea.toString() + juce::newLine +
                                  "userArea: " + display->userArea.toString() + juce::newLine +
                                  "safeBounds: " + safeBounds.toString() + juce::newLine +
                                  "kiosk mode: " + (isKioskMode() ? "on" : "off") + juce::newLine +
                                  "app style: " + (isDarkStyle() ? "dark" : "light"));

        g.fillAll (isDarkStyle() ? juce::Colours::darkblue : juce::Colours::lightblue);

        g.setColour (isDarkStyle() ? juce::Colours::darkgreen : juce::Colours::lightgreen);
        g.fillRect (safeBounds);

        g.setColour (juce::Colours::red);
        g.drawRect (safeBounds.reduced (4), 3.0f);

        g.setColour (isDarkStyle() ? juce::Colours::white : juce::Colours::black);
        g.drawFittedText (displayInfo, safeBounds.reduced (10), juce::Justification::topLeft, 30);
    }

    bool isKioskMode()
    {
        return juce::Desktop::getInstance().getKioskModeComponent() != nullptr;
    }

    void toggleKioskMode()
    {
        juce::Desktop::getInstance().setKioskModeComponent (isKioskMode() ? nullptr : getTopLevelComponent());

        if (auto* peer = getPeer())
            peer->setFullScreen (true);
    }

    bool isDarkStyle()
    {
        if (auto* peer = getPeer())
            return peer->getAppStyle() == juce::ComponentPeer::Style::dark;

        return false;
    }

    void toggleAppStyle()
    {
        if (auto* peer = getPeer())
            peer->setAppStyle (isDarkStyle() ? juce::ComponentPeer::Style::light
                                             : juce::ComponentPeer::Style::dark);
                                             
        repaint();
    }

private:
    juce::TextButton kioskButton;
    juce::TextButton styleButton;
    juce::TextButton bluetoothButton;

    JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (MainComponent)
};

class NewProjectApplication  : public juce::JUCEApplication
{
public:
    NewProjectApplication() {}
    const juce::String getApplicationName() override            { return ProjectInfo::projectName; }
    const juce::String getApplicationVersion() override         { return ProjectInfo::versionString; }
    bool moreThanOneInstanceAllowed() override                  { return false; }
    void initialise (const juce::String& commandLine) override  { mainWindow.reset (new MainWindow (getApplicationName())); }
    void shutdown() override                                    { mainWindow = nullptr; }
    void systemRequestedQuit() override                         { quit(); }

    class MainWindow    : public juce::DocumentWindow
    {
    public:
#if JUCE_ANDROID
        juce::OpenGLContext openGLContext;
#endif

        MainWindow (juce::String name)
            : DocumentWindow (name, juce::Colours::grey, DocumentWindow::allButtons)
        {
#if JUCE_ANDROID
            openGLContext.attachTo (*this);
#endif

            setUsingNativeTitleBar (true);
            setContentOwned (new MainComponent(), false);

           #if JUCE_IOS || JUCE_ANDROID
            setFullScreen (true);
           #else
            centreWithSize (600, 400);
           #endif

            setResizable (true, true);
            setVisible (true);
            
            getPeer()->setAppStyle (juce::ComponentPeer::Style::dark);
        }
        
        void closeButtonPressed() override { JUCEApplication::getInstance()->systemRequestedQuit(); }

        ~MainWindow()
        {
#if JUCE_ANDROID
            openGLContext.detach();
#endif
            clearContentComponent();
        }
    
        JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (MainWindow)
    };

private:
    std::unique_ptr<MainWindow> mainWindow;
};

START_JUCE_APPLICATION (NewProjectApplication)

Thanks for the example code. Before I investigate further, please could you confirm the Android version(s) where you’re seeing this issue, and whether you can reproduce it in an emulator?

Yes, I did a quick test in an emulator (Pixel 4a, API 31) and could reproduce this too.
My devices:
Google Pixel 4a (5G), API 34.
Samsung Galaxy A22 5G, API 33 & One UI 5.1.
I am on the latest JUCE develop tip.