Repository: nvaccess/nvda

Issue:
NVDA reports "has attachment" for messages without visible attachments in Outlook
### Steps to reproduce:
1. In outlook, send yourself a message containing a picture in the message body.
2. List the message in the Inbox

### Expected behavior:
The picture is part of the message body, so NVDA shouldn't announce "has attachments".

### Actual behavior:
NVDA announces has attachment. However, when you press enter on the message to open it, than shift+tab to go to the list of attachments, there simply is no such list.

### Additional information
* The attachments cell for this grid item is named "No attachments"
* This issue is caused by the Outlook object model not only exposing explicitly added attachments, but also implicitly added ones (i.e. in a message's body).

### Proposed fix
Remove the attachment checking altogether and rely on the UIA information. For this, the children cache request we use to fetch the child nodes of a grid row should no longer only list text nodes. Note that this also allows us to rely on UIA information for the importance status. I think relying on UIA here is preferred due to the following reasons:

1. It fixes the attachments bug as pointed out above
2. It respects different view setups for outlook. Imagine a case where one edits his view to make it expose the attachments status as text rather than as an icon. This would lead to double speaking of information.

It is not advised to rely on UIA only for the flag status, as unflagged message grid rows still expose a child node named "unflagged". This is redudant information, but can easily be worked around by filtering on the UIA Class name "FlagIcon".

### System configuration:
NVDA version: 2017.4
NVDA Installed
Windows version: Windows 10 fall creators update
Outlook version: 16.0.9001.2102




Discussion:

Resolving patch:
diff --git a/source/appModules/outlook.py b/source/appModules/outlook.py
--- a/source/appModules/outlook.py
+++ b/source/appModules/outlook.py
@@ -1,6 +1,6 @@
 #appModules/outlook.py
 #A part of NonVisual Desktop Access (NVDA)
-#Copyright (C) 2006-2014 NVDA Contributors <http://www.nvaccess.org/>
+#Copyright (C) 2006-2018 NV Access Limited, Yogesh Kumar, Manish Agrawal, Joseph Lee, Davy Kager, Babbage B.V.
 #This file is covered by the GNU General Public License.
 #See the file COPYING for more details.
 
@@ -29,6 +29,18 @@
 from NVDAObjects.behaviors import RowWithFakeNavigation, Dialog
 from NVDAObjects.UIA import UIA
 
+#: When in a list view, the message classes which should not be announced for an item.
+#: For these, it should be safe to assume that their names consist of only one word.
+silentMessageClasses = [
+	"IPM.Appointment",
+	"IPM.Contact",
+	"IPM.Note", # The class for a message
+]
+
+#: The number of seconds in a day, used to make all day appointments and selections less verbose.
+#: Type: float
+SECONDS_PER_DAY = 86400.0
+
 oleFlagIconLabels={
 	# Translators: a flag for a Microsoft Outlook message
 	# See https://msdn.microsoft.com/en-us/library/office/aa211991(v=office.11).aspx
@@ -349,15 +361,21 @@ class CalendarView(IAccessible):
 	_lastStartDate=None
 
 	def _generateTimeRangeText(self,startTime,endTime):
-		startText=winKernel.GetTimeFormat(winKernel.LOCALE_USER_DEFAULT, winKernel.TIME_NOSECONDS, startTime, None)
-		endText=winKernel.GetTimeFormat(winKernel.LOCALE_USER_DEFAULT, winKernel.TIME_NOSECONDS, endTime, None)
+		startText=winKernel.GetTimeFormatEx(winKernel.LOCALE_NAME_USER_DEFAULT, winKernel.TIME_NOSECONDS, startTime, None)
+		endText=winKernel.GetTimeFormatEx(winKernel.LOCALE_NAME_USER_DEFAULT, winKernel.TIME_NOSECONDS, endTime, None)
 		startDate=startTime.date()
 		endDate=endTime.date()
 		if not CalendarView._lastStartDate or startDate!=CalendarView._lastStartDate or endDate!=startDate: 
-			startText="%s %s"%(winKernel.GetDateFormat(winKernel.LOCALE_USER_DEFAULT, winKernel.DATE_LONGDATE, startTime, None),startText)
-		if endDate!=startDate:
-			endText="%s %s"%(winKernel.GetDateFormat(winKernel.LOCALE_USER_DEFAULT, winKernel.DATE_LONGDATE, endTime, None),endText)
+			startDateText=winKernel.GetDateFormatEx(winKernel.LOCALE_NAME_USER_DEFAULT, winKernel.DATE_LONGDATE, startTime, None)
+			startText="%s %s"%(startDateText,startText)
 		CalendarView._lastStartDate=startDate
+		if endDate!=startDate:
+			if ((startTime.hour, startTime.minute, startTime.second) == (0, 0, 0) and
+				(endDate - startDate).total_seconds()==SECONDS_PER_DAY
+			):
+				# Translators: a message reporting the date of a all day Outlook calendar entry
+				return _("{date} (all day)").format(date=startDateText)
+			endText="%s %s"%(winKernel.GetDateFormatEx(winKernel.LOCALE_NAME_USER_DEFAULT, winKernel.DATE_LONGDATE, endTime, None),endText)
 		# Translators: a message reporting the time range (i.e. start time to end time) of an Outlook calendar entry
 		return _("{startTime} to {endTime}").format(startTime=startText,endTime=endText)
 
@@ -392,8 +410,8 @@ def reportFocus(self):
 				except COMError:
 					return super(CalendarView,self).reportFocus()
 				timeSlotText=self._generateTimeRangeText(selectedStartTime,selectedEndTime)
-				startLimit=u"%s %s"%(winKernel.GetDateFormat(winKernel.LOCALE_USER_DEFAULT, winKernel.DATE_LONGDATE, selectedStartTime, None),winKernel.GetTimeFormat(winKernel.LOCALE_USER_DEFAULT, winKernel.TIME_NOSECONDS, selectedStartTime, None))
-				endLimit=u"%s %s"%(winKernel.GetDateFormat(winKernel.LOCALE_USER_DEFAULT, winKernel.DATE_LONGDATE, selectedEndTime, None),winKernel.GetTimeFormat(winKernel.LOCALE_USER_DEFAULT, winKernel.TIME_NOSECONDS, selectedEndTime, None))
+				startLimit=u"%s %s"%(winKernel.GetDateFormatEx(winKernel.LOCALE_NAME_USER_DEFAULT, winKernel.DATE_LONGDATE, selectedStartTime, None),winKernel.GetTimeFormat(winKernel.LOCALE_USER_DEFAULT, winKernel.TIME_NOSECONDS, selectedStartTime, None))
+				endLimit=u"%s %s"%(winKernel.GetDateFormatEx(winKernel.LOCALE_NAME_USER_DEFAULT, winKernel.DATE_LONGDATE, selectedEndTime, None),winKernel.GetTimeFormat(winKernel.LOCALE_USER_DEFAULT, winKernel.TIME_NOSECONDS, selectedEndTime, None))
 				query=u'[Start] < "{endLimit}" And [End] > "{startLimit}"'.format(startLimit=startLimit,endLimit=endLimit)
 				i=e.currentFolder.items
 				i.sort('[Start]')
@@ -427,8 +445,10 @@ def _get_name(self):
 				unread=selection.unread
 			except COMError:
 				unread=False
-			# Translators: when an email is unread
-			if unread: textList.append(_("unread"))
+			try:
+				messageClass=selection.messageClass
+			except COMError:
+				messageClass=None
 			try:
 				flagIcon=selection.flagIcon
 			except COMError:
@@ -447,26 +467,41 @@ def _get_name(self):
 				importance=1
 			importanceLabel=importanceLabels.get(importance)
 			if importanceLabel: textList.append(importanceLabel)
-			try:
-				messageClass=selection.messageClass
-			except COMError:
-				messageClass=None
-			if messageClass=="IPM.Schedule.Meeting.Request":
-				# Translators: the email is a meeting request
-				textList.append(_("meeting request"))
+			if self.appModule.outlookVersion<15:
+				# Translators: when an email is unread
+				if unread: textList.append(_("unread"))
+				if messageClass=="IPM.Schedule.Meeting.Request":
+					# Translators: the email is a meeting request
+					textList.append(_("meeting request"))
+			elif messageClass is not None:
+				# Replied or forwarded state for this message is available from the object's value.
+				# We must parse this value correctly, as it may contain redundant information, such as the message class and read value.
+				# We only expose the unread state, and message class for non-messages.
+				# The several states are localized and separated by a space.
+				# Example output: 'Meeting request Replied Read'
+				valueParts = self._getUIACacheablePropertyValue(UIAHandler.UIA_ValueValuePropertyId).split(" ")
+				valueCount = len(valueParts)
+				# The last valuePart indicates whether the message is read or unread.
+				# Do not expose the read state
+				lastPart = valueCount if unread else valueCount-1
+				# The first valuePart is the type of the selection, e.g. Message, Contact.
+				# We can safely assume that the classes in silentMessageClasses are one word.
+				# For messages other than regular mail messages (e.g. meeting request), the message class is relevant.
+				firstPart = max(1, valueCount-2) if messageClass in silentMessageClasses else 0
+				textList.extend(valueParts[firstPart:lastPart])
 		childrenCacheRequest=UIAHandler.handler.baseCacheRequest.clone()
 		childrenCacheRequest.addProperty(UIAHandler.UIA_NamePropertyId)
 		childrenCacheRequest.addProperty(UIAHandler.UIA_TableItemColumnHeaderItemsPropertyId)
 		childrenCacheRequest.TreeScope=UIAHandler.TreeScope_Children
 		childrenCacheRequest.treeFilter=UIAHandler.handler.clientObject.createPropertyCondition(UIAHandler.UIA_ControlTypePropertyId,UIAHandler.UIA_TextControlTypeId)
 		cachedChildren=self.UIAElement.buildUpdatedCache(childrenCacheRequest).getCachedChildren()
+		if not cachedChildren:
+			# There are no children
+			# This is unexpected here.
+			log.debugWarning("Unable to get relevant children for UIAGridRow", stack_info=True)
+			return super(UIAGridRow, self).name
 		for index in xrange(cachedChildren.length):
 			e=cachedChildren.getElement(index)
-			# #6219: Outlook 2016 started exposing the draft column as a text node.
-			# Users reportedly find this extremely annoying.
-			# Thus we filter it out.
-			if self.appModule.outlookVersion>=16 and e.cachedClassName=="DraftFlagField":
-				continue
 			name=e.cachedName
 			columnHeaderTextList=[]
 			if name and config.conf['documentFormatting']['reportTableHeaders']:
@@ -484,7 +519,7 @@ def _get_name(self):
 			else:
 				text=name
 			if text:
-				text=text+u","
+				text+=u","
 				textList.append(text)
 		return " ".join(textList)
 
@@ -581,4 +616,3 @@ class DatePickerCell(IAccessible):
 	# Therefore we cannot safely filter out duplicates
 	def isDuplicateIAccessibleEvent(self,obj):
 		return False
-
diff --git a/source/globalCommands.py b/source/globalCommands.py
--- a/source/globalCommands.py
+++ b/source/globalCommands.py
@@ -212,9 +212,9 @@ def script_reportCurrentSelection(self,gesture):
 
 	def script_dateTime(self,gesture):
 		if scriptHandler.getLastScriptRepeatCount()==0:
-			text=winKernel.GetTimeFormat(winKernel.LOCALE_USER_DEFAULT, winKernel.TIME_NOSECONDS, None, None)
+			text=winKernel.GetTimeFormatEx(winKernel.LOCALE_NAME_USER_DEFAULT, winKernel.TIME_NOSECONDS, None, None)
 		else:
-			text=winKernel.GetDateFormat(winKernel.LOCALE_USER_DEFAULT, winKernel.DATE_LONGDATE, None, None)
+			text=winKernel.GetDateFormatEx(winKernel.LOCALE_NAME_USER_DEFAULT, winKernel.DATE_LONGDATE, None, None)
 		ui.message(text)
 	# Translators: Input help mode message for report date and time command.
 	script_dateTime.__doc__=_("If pressed once, reports the current time. If pressed twice, reports the current date")
diff --git a/source/winKernel.py b/source/winKernel.py
--- a/source/winKernel.py
+++ b/source/winKernel.py
@@ -33,6 +33,7 @@
 STD_OUTPUT_HANDLE=-11
 STD_ERROR_HANDLE=-12
 LOCALE_USER_DEFAULT=0x0400
+LOCALE_NAME_USER_DEFAULT=None
 DATE_LONGDATE=0x00000002 
 TIME_NOSECONDS=0x00000002
 
@@ -140,6 +141,7 @@ class SYSTEMTIME(ctypes.Structure):
 	)
 
 def GetDateFormat(Locale,dwFlags,date,lpFormat):
+	"""@Deprecated: use GetDateFormatEx instead."""
 	if date is not None:
 		date=SYSTEMTIME(date.year,date.month,0,date.day,date.hour,date.minute,date.second,0)
 		lpDate=byref(date)
@@ -150,7 +152,19 @@ def GetDateFormat(Locale,dwFlags,date,lpFormat):
 	kernel32.GetDateFormatW(Locale, dwFlags, lpDate, lpFormat, buf, bufferLength)
 	return buf.value
 
+def GetDateFormatEx(Locale,dwFlags,date,lpFormat):
+	if date is not None:
+		date=SYSTEMTIME(date.year,date.month,0,date.day,date.hour,date.minute,date.second,0)
+		lpDate=byref(date)
+	else:
+		lpDate=None
+	bufferLength=kernel32.GetDateFormatEx(Locale, dwFlags, lpDate, lpFormat, None, 0, None)
+	buf=ctypes.create_unicode_buffer("", bufferLength)
+	kernel32.GetDateFormatEx(Locale, dwFlags, lpDate, lpFormat, buf, bufferLength, None)
+	return buf.value
+
 def GetTimeFormat(Locale,dwFlags,date,lpFormat):
+	"""@Deprecated: use GetTimeFormatEx instead."""
 	if date is not None:
 		date=SYSTEMTIME(date.year,date.month,0,date.day,date.hour,date.minute,date.second,0)
 		lpTime=byref(date)
@@ -161,6 +175,17 @@ def GetTimeFormat(Locale,dwFlags,date,lpFormat):
 	kernel32.GetTimeFormatW(Locale,dwFlags,lpTime,lpFormat, buf, bufferLength)
 	return buf.value
 
+def GetTimeFormatEx(Locale,dwFlags,date,lpFormat):
+	if date is not None:
+		date=SYSTEMTIME(date.year,date.month,0,date.day,date.hour,date.minute,date.second,0)
+		lpTime=byref(date)
+	else:
+		lpTime=None
+	bufferLength=kernel32.GetTimeFormatEx(Locale,dwFlags,lpTime,lpFormat, None, 0)
+	buf=ctypes.create_unicode_buffer("", bufferLength)
+	kernel32.GetTimeFormatEx(Locale,dwFlags,lpTime,lpFormat, buf, bufferLength)
+	return buf.value
+
 def openProcess(*args):
 	return kernel32.OpenProcess(*args)
 


Test patch: