Skip to content

Commit

Permalink
add unit tests for lockscreen order tracking
Browse files Browse the repository at this point in the history
  • Loading branch information
seanbudd committed Dec 5, 2022
1 parent 6039b53 commit b78dc61
Show file tree
Hide file tree
Showing 3 changed files with 199 additions and 23 deletions.
9 changes: 9 additions & 0 deletions source/NVDAObjects/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
TreeInterceptor,
)
import braille
from utils.security import _isObjectAboveLockScreen
import vision
import globalPluginHandler
import brailleInput
Expand Down Expand Up @@ -1433,3 +1434,11 @@ def getSelectedItemsCount(self,maxCount=2):
For performance, this method will only count up to the given maxCount number, and if there is one more above that, then sys.maxint is returned stating that many items are selected.
"""
return 0

#: Type definition for auto prop '_get_isAboveLockScreen'
isAboveLockScreen: bool

def _get_isAboveLockScreen(self) -> bool:
if not isWindowsLocked():
return True
return _isObjectAboveLockScreen(self)
92 changes: 69 additions & 23 deletions source/utils/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

import typing
from typing import (
Callable,
Optional,
Set,
)

Expand Down Expand Up @@ -143,7 +145,7 @@ def _isSecureObjectWhileLockScreenActivated(
@return: C{True} if the Windows 10/11 lockscreen is active and C{obj} is outside of the lock screen.
"""
try:
isObjectInSecure = isWindowsLocked() and not isObjectAboveLockScreen(obj)
isObjectInSecure = isWindowsLocked() and not obj.isAboveLockScreen
except Exception:
log.exception()
return False
Expand All @@ -157,7 +159,7 @@ def _isSecureObjectWhileLockScreenActivated(
return False


def isObjectAboveLockScreen(obj: "NVDAObjects.NVDAObject") -> bool:
def _isObjectAboveLockScreen(obj: "NVDAObjects.NVDAObject") -> bool:
"""
When Windows is locked, the foreground Window is usually LockApp,
but other Windows can be focused (e.g. Windows Magnifier).
Expand All @@ -182,10 +184,28 @@ def isObjectAboveLockScreen(obj: "NVDAObjects.NVDAObject") -> bool:
or isinstance(obj, SecureDesktopNVDAObject)
):
return True
return _isObjectAboveLockScreenCheckZOrder(obj)

import appModuleHandler
runningAppModules = appModuleHandler.runningTable.values()
lockAppModule = next(filter(_isLockAppAndAlive, runningAppModules), None)

if lockAppModule is None:
# lockAppModule not running/registered by NVDA yet
log.debug(
"lockAppModule not detected when Windows is locked. "
"Cannot detect if object is in lock app, considering object as safe. "
)
return True

def _isObjectAboveLockScreenCheckZOrder(obj: "NVDAObjects.NVDAObject") -> bool:
from NVDAObjects.window import Window
if not isinstance(obj, Window):
# must be a window to get its HWNDVal
return True

return _isObjectAboveLockScreenCheckZOrder(obj.windowHandle, lockAppModule.processID)


def _isObjectAboveLockScreenCheckZOrder(objWindowHandle: int, lockAppModuleProcessId: int) -> bool:
"""
This is a risky hack.
If the order is incorrectly detected,
Expand All @@ -195,32 +215,58 @@ def _isObjectAboveLockScreenCheckZOrder(obj: "NVDAObjects.NVDAObject") -> bool:
If these functions fail, where possible,
NVDA should make NVDA objects accessible.
"""
import appModuleHandler
from NVDAObjects.window import Window
if not isinstance(obj, Window):
# must be a window to get its HWNDVal
return True
runningAppModules = appModuleHandler.runningTable.values()
lockAppModule = next(filter(_isLockAppAndAlive, runningAppModules), None)

if lockAppModule is None:
# lockAppModule not running/registered by NVDA yet
log.debug(
"lockAppModule not detected when Windows is locked. "
"Cannot detect if object is in lock app, considering object as safe. "
)
def _isWindowLockApp(hwnd: winUser.HWNDVal) -> bool:
windowProcessId, _threadId = winUser.getWindowThreadProcessID(hwnd)
return windowProcessId == lockAppModuleProcessId

def _isNVDAObjectWindow(hwnd: winUser.HWNDVal) -> bool:
return hwnd == objWindowHandle

lockAppZIndex = _getWindowZIndex(_isWindowLockApp)
objectZIndex = _getWindowZIndex(_isNVDAObjectWindow)
lockAppZIndexCheck = _getWindowZIndex(_isWindowLockApp)
objectZIndexCheck = _getWindowZIndex(_isNVDAObjectWindow)
if lockAppZIndex != lockAppZIndexCheck or objectZIndex != objectZIndexCheck:
log.debugWarning("Order of Windows has changed during execution")

if lockAppZIndex is None or lockAppZIndexCheck is None:
# this is an unexpected state
# err on accessibility
log.error("Couldn't find lock screen")
return True
elif objectZIndex is None or objectZIndexCheck is None:
# this is an unexpected state
# err on accessibility
log.error("Couldn't find NVDA object's window")
return True
elif lockAppZIndex > objectZIndex and lockAppZIndexCheck > objectZIndexCheck:
# object is behind the lock screen, hide it from the user
return False
elif lockAppZIndex <= objectZIndex and lockAppZIndexCheck <= objectZIndexCheck:
# object is above the lock screen, show it to the user
return True
else:
log.debugWarning("Z-index of Windows has changed, unable to determine z-order")
# mixed state between checks
# err on accessibility
return True


def _getWindowZIndex(matchCond: Callable[[winUser.HWNDVal], bool]) -> Optional[int]:
"""
Z-order can change while this is being checked.
This means this may not always return the correct result.
"""
desktopWindow = winUser.getDesktopWindow()
nextWindow = winUser.getTopWindow(desktopWindow)
index = 0
while nextWindow:
windowProcessId = winUser.getWindowThreadProcessID(nextWindow)
if nextWindow == obj.windowHandle:
return True
elif windowProcessId == lockAppModule.processID:
return False
if matchCond(nextWindow):
return index
nextWindow = winUser.getWindow(nextWindow, winUser.GW_HWNDNEXT)
return False
index += 1
return None


_hasSessionLockStateUnknownWarningBeenGiven = False
Expand Down
121 changes: 121 additions & 0 deletions tests/unit/test_util/test_security.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# A part of NonVisual Desktop Access (NVDA)
# This file is covered by the GNU General Public License.
# See the file COPYING for more details.
# Copyright (C) 2022 NV Access Limited.

"""Unit tests for the blockUntilConditionMet submodule.
"""

import unittest
from unittest.mock import patch

from utils.security import (
_getWindowZIndex,
)
import winUser


class _Test_getWindowZIndex(unittest.TestCase):
def _getWindow_patched(self, hwnd: winUser.HWNDVal, relation: int) -> int:
currentWindowIndex = self._windows.index(hwnd)
if relation == winUser.GW_HWNDNEXT:
try:
return self._windows[currentWindowIndex + 1]
except IndexError:
return 0
elif relation == winUser.GW_HWNDPREV:
try:
return self._windows[currentWindowIndex - 1]
except IndexError:
return 0
else:
return 0

def _windowMatches(self, expectedWindow: int):
def _helper(hwnd: int) -> bool:
return hwnd == expectedWindow
return _helper

def setUp(self) -> None:
self._getDesktopWindowPatch = patch("winUser.getDesktopWindow", lambda: 0) # value is discarded by _getTopWindowPatch
self._getTopWindowPatch = patch("winUser.getTopWindow", lambda _hwnd: self._windows[0])
self._getWindowPatch = patch("winUser.getWindow", self._getWindow_patched)
self._getDesktopWindowPatch.start()
self._getTopWindowPatch.start()
self._getWindowPatch.start()
self._windows = list(range(1, 11)) # must be 1 indexed
return super().setUp()

def tearDown(self) -> None:
self._getDesktopWindowPatch.stop()
self._getTopWindowPatch.stop()
self._getWindowPatch.stop()
return super().tearDown()


class Test_getWindowZIndex_static(_Test_getWindowZIndex):
def test_noMatch(self):
self.assertIsNone(_getWindowZIndex(lambda x: False))

def test_firstWindowMatch_noChanges(self):
targetWindow = 1
expectedIndex = targetWindow - 1
self.assertEqual(expectedIndex, _getWindowZIndex(self._windowMatches(targetWindow)))

def test_lastWindowMatch_noChanges(self):
targetWindow = len(self._windows)
expectedIndex = targetWindow - 1
self.assertEqual(expectedIndex, _getWindowZIndex(self._windowMatches(targetWindow)))


class Test_getWindowZIndex_dynamic(_Test_getWindowZIndex):
_triggered = False

def _getWindow_patched(self, hwnd: winUser.HWNDVal, relation: int) -> int:
self._moveIndexToNewIndexAtIndexOnce(self._windows.index(hwnd))
result = super()._getWindow_patched(hwnd, relation)
return result

def _moveIndexToNewIndexAtIndexOnce(self, currentIndex: int):
from logging import getLogger

getLogger().error(f"{currentIndex}")
if currentIndex == self._triggerIndex and not self._triggered:
self._triggered = True
window = self._windows.pop(self._startIndex)
self._windows.insert(self._endIndex, window)

def test_prev_windowMoves_pastTarget(self):
"""A previous window is moved past the target window. This does not affect the z-order."""
self._startIndex = 2
self._endIndex = 9
self._triggerIndex = 5
targetWindow = 7
expectedIndex = targetWindow - 1
self.assertEqual(expectedIndex, _getWindowZIndex(self._windowMatches(targetWindow)))

def test_prev_windowMoves_beforeTarget(self):
"""A previous window is moved before the target window. It is counted twice."""
self._startIndex = 2
self._endIndex = 5
self._triggerIndex = 3
targetWindow = 7
expectedIndex = targetWindow # difference is normally 1, however a window is counted twice
self.assertEqual(expectedIndex, _getWindowZIndex(self._windowMatches(targetWindow)))

def test_active_windowMoves_pastTarget(self):
"""The window we are looking at moves past the match, skipping our target window"""
self._startIndex = 3
self._endIndex = 8
self._triggerIndex = 3
targetWindow = 6
self.assertEqual(None, _getWindowZIndex(self._windowMatches(targetWindow)))

def test_active_windowMoves_beforeTarget(self):
pass

def test_future_windowMoves_pastTarget(self):
pass

def test_future_windowMoves_beforeTarget(self):
pass

0 comments on commit b78dc61

Please sign in to comment.