combine completed filters if a timeout occurs

A user reported that they were woken up from an incoming call even
though their DND settings are set to disallow callers.  Upon inspection,
the DND filter was computed and the caller was silenced BUT another
filter timed out causing the final result to allow the caller.

Now, if the DND filter or any other filter is processed and finished
before the timeout, the fitlers will be combined with the final result.

Flag: com.android.server.telecom.flags.check_completed_filters_on_timeout
Bug:   364871465 (root bug)
Fixes: 364946812 (spun off bug)
Test: com.android.server.telecom.tests.IncomingCallFilterGraphTest
                   #testFilterTimesOutWithDndFilterComputedAlready
Change-Id: I88178b4c5b1e76a2c0108092f52bb6d8ec6884b6
diff --git a/flags/telecom_call_filtering_flags.aconfig b/flags/telecom_call_filtering_flags.aconfig
index d80cfa3..693d727 100644
--- a/flags/telecom_call_filtering_flags.aconfig
+++ b/flags/telecom_call_filtering_flags.aconfig
@@ -7,4 +7,15 @@
   namespace: "telecom"
   description: "Gates whether to still perform Dnd filter when phone account has skip_filter call extra."
   bug: "222333869"
-}
\ No newline at end of file
+}
+
+# OWNER=tjstuart TARGET=25Q1
+flag {
+  name: "check_completed_filters_on_timeout"
+  namespace: "telecom"
+  description: "If the Filtering Graph times out, combine the finished results"
+  bug: "364946812"
+  metadata {
+    purpose: PURPOSE_BUGFIX
+  }
+}
diff --git a/src/com/android/server/telecom/CallsManager.java b/src/com/android/server/telecom/CallsManager.java
index a7ad3b8..1cc212e 100644
--- a/src/com/android/server/telecom/CallsManager.java
+++ b/src/com/android/server/telecom/CallsManager.java
@@ -913,7 +913,7 @@
         DndCallFilter dndCallFilter = new DndCallFilter(incomingHfpCall, mRinger);
         IncomingCallFilterGraph graph = mIncomingCallFilterGraphProvider.createGraph(
                 incomingHfpCall,
-                this::onCallFilteringComplete, mContext, mTimeoutsAdapter, mLock);
+                this::onCallFilteringComplete, mContext, mTimeoutsAdapter, mFeatureFlags, mLock);
         graph.addFilter(dndCallFilter);
         mGraphHandlerThreads.add(graph.getHandlerThread());
         return graph;
@@ -932,7 +932,7 @@
         ParcelableCallUtils.Converter converter = new ParcelableCallUtils.Converter();
 
         IncomingCallFilterGraph graph = mIncomingCallFilterGraphProvider.createGraph(incomingCall,
-                this::onCallFilteringComplete, mContext, mTimeoutsAdapter, mLock);
+                this::onCallFilteringComplete, mContext, mTimeoutsAdapter, mFeatureFlags, mLock);
         DirectToVoicemailFilter voicemailFilter = new DirectToVoicemailFilter(incomingCall,
                 mCallerInfoLookupHelper);
         BlockCheckerFilter blockCheckerFilter = new BlockCheckerFilter(mContext, incomingCall,
diff --git a/src/com/android/server/telecom/callfiltering/IncomingCallFilterGraph.java b/src/com/android/server/telecom/callfiltering/IncomingCallFilterGraph.java
index d79e80e..a606a4d 100644
--- a/src/com/android/server/telecom/callfiltering/IncomingCallFilterGraph.java
+++ b/src/com/android/server/telecom/callfiltering/IncomingCallFilterGraph.java
@@ -27,6 +27,7 @@
 import com.android.server.telecom.LogUtils;
 import com.android.server.telecom.TelecomSystem;
 import com.android.server.telecom.Timeouts;
+import com.android.server.telecom.flags.FeatureFlags;
 
 import java.util.ArrayList;
 import java.util.List;
@@ -55,6 +56,7 @@
     private CallFilteringResult mCurrentResult;
     private Context mContext;
     private Timeouts.Adapter mTimeoutsAdapter;
+    private final FeatureFlags mFeatureFlags;
 
     private class PostFilterTask {
         private final CallFilter mFilter;
@@ -84,11 +86,12 @@
     }
 
     public IncomingCallFilterGraph(Call call, CallFilterResultCallback listener, Context context,
-            Timeouts.Adapter timeoutsAdapter, TelecomSystem.SyncRoot lock) {
+            Timeouts.Adapter timeoutsAdapter, FeatureFlags featureFlags,
+            TelecomSystem.SyncRoot lock) {
         mListener = listener;
         mCall = call;
         mFiltersList = new ArrayList<>();
-
+        mFeatureFlags = featureFlags;
         mHandlerThread = new HandlerThread(TAG);
         mHandlerThread.start();
         mHandler = new Handler(mHandlerThread.getLooper());
@@ -121,8 +124,8 @@
             @Override
             public void loggedRun() {
                 if (!mFinished) {
-                    Log.i(this, "Graph timed out when performing filtering.");
                     Log.addEvent(mCall, LogUtils.Events.FILTERING_TIMED_OUT);
+                    mCurrentResult = onTimeoutCombineFinishedFilters(mFiltersList, mCurrentResult);
                     mListener.onCallFilteringComplete(mCall, mCurrentResult, true);
                     mFinished = true;
                     mHandlerThread.quit();
@@ -137,6 +140,28 @@
         }.prepare(), mTimeoutsAdapter.getCallScreeningTimeoutMillis(mContext.getContentResolver()));
     }
 
+    /**
+     * This helper takes all the call filters that were added to the graph, checks if filters have
+     * finished, and combines the results.
+     *
+     * @param filtersList   all the CallFilters that were added to the call
+     * @param currentResult the current call filter result
+     * @return CallFilterResult of the combined finished Filters.
+     */
+    private CallFilteringResult onTimeoutCombineFinishedFilters(
+            List<CallFilter> filtersList,
+            CallFilteringResult currentResult) {
+        if (!mFeatureFlags.checkCompletedFiltersOnTimeout()) {
+            return currentResult;
+        }
+        for (CallFilter filter : filtersList) {
+            if (filter.result != null) {
+                currentResult = currentResult.combine(filter.result);
+            }
+        }
+        return currentResult;
+    }
+
     private void scheduleFilter(CallFilter filter) {
         CallFilteringResult result = new CallFilteringResult.Builder()
                 .setShouldAllowCall(true)
@@ -147,6 +172,9 @@
                 .setDndSuppressed(false)
                 .build();
         for (CallFilter dependencyFilter : filter.getDependencies()) {
+            // When sequential nodes are completed, they are combined progressively.
+            // ex.) node_a --> node_b  --> node_c
+            // node_a will combine with node_b before starting node_c
             result = result.combine(dependencyFilter.getResult());
         }
         mCurrentResult = result;
diff --git a/src/com/android/server/telecom/callfiltering/IncomingCallFilterGraphProvider.java b/src/com/android/server/telecom/callfiltering/IncomingCallFilterGraphProvider.java
index 1501280..4424178 100644
--- a/src/com/android/server/telecom/callfiltering/IncomingCallFilterGraphProvider.java
+++ b/src/com/android/server/telecom/callfiltering/IncomingCallFilterGraphProvider.java
@@ -21,6 +21,7 @@
 import com.android.server.telecom.Call;
 import com.android.server.telecom.TelecomSystem;
 import com.android.server.telecom.Timeouts;
+import com.android.server.telecom.flags.FeatureFlags;
 
 /**
  * Interface to provide a {@link IncomingCallFilterGraph}. This class serve for unit test purpose
@@ -35,10 +36,13 @@
      * @param listener Callback object to trigger when filtering is done.
      * @param context An android context.
      * @param timeoutsAdapter Adapter to provide timeout value for call filtering.
+     * @param featureFlags Telecom flags
      * @param lock Telecom lock.
      * @return
      */
     IncomingCallFilterGraph createGraph(Call call, CallFilterResultCallback listener,
             Context context,
-            Timeouts.Adapter timeoutsAdapter, TelecomSystem.SyncRoot lock);
+            Timeouts.Adapter timeoutsAdapter,
+            FeatureFlags featureFlags,
+            TelecomSystem.SyncRoot lock);
 }
diff --git a/tests/src/com/android/server/telecom/tests/CallsManagerTest.java b/tests/src/com/android/server/telecom/tests/CallsManagerTest.java
index baf0208..74f33c3 100644
--- a/tests/src/com/android/server/telecom/tests/CallsManagerTest.java
+++ b/tests/src/com/android/server/telecom/tests/CallsManagerTest.java
@@ -392,7 +392,8 @@
                 mBluetoothDeviceManager,
                 mFeatureFlags,
                 mTelephonyFlags,
-                (call, listener, context, timeoutsAdapter, lock) -> mIncomingCallFilterGraph);
+                (call, listener, context, timeoutsAdapter,
+                        mFeatureFlags, lock) -> mIncomingCallFilterGraph);
 
         when(mPhoneAccountRegistrar.getPhoneAccount(
                 eq(SELF_MANAGED_HANDLE), any())).thenReturn(SELF_MANAGED_ACCOUNT);
diff --git a/tests/src/com/android/server/telecom/tests/IncomingCallFilterGraphTest.java b/tests/src/com/android/server/telecom/tests/IncomingCallFilterGraphTest.java
index 66ac553..d7905b2 100644
--- a/tests/src/com/android/server/telecom/tests/IncomingCallFilterGraphTest.java
+++ b/tests/src/com/android/server/telecom/tests/IncomingCallFilterGraphTest.java
@@ -17,22 +17,28 @@
 package com.android.server.telecom.tests;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
 import static org.mockito.ArgumentMatchers.nullable;
+import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 
 import android.content.ContentResolver;
 import android.content.Context;
 import android.os.Handler;
 import android.os.HandlerThread;
+import android.util.Log;
 
 import androidx.test.filters.SmallTest;
 
 import com.android.server.telecom.Call;
+import com.android.server.telecom.Ringer;
 import com.android.server.telecom.TelecomSystem;
 import com.android.server.telecom.Timeouts;
 import com.android.server.telecom.callfiltering.CallFilter;
 import com.android.server.telecom.callfiltering.CallFilterResultCallback;
 import com.android.server.telecom.callfiltering.CallFilteringResult;
+import com.android.server.telecom.callfiltering.DndCallFilter;
 import com.android.server.telecom.callfiltering.IncomingCallFilterGraph;
 
 import org.junit.Before;
@@ -47,6 +53,7 @@
 
 @RunWith(JUnit4.class)
 public class IncomingCallFilterGraphTest extends TelecomTestCase {
+    private final String TAG = IncomingCallFilterGraphTest.class.getSimpleName();
     @Mock private Call mCall;
     @Mock private Context mContext;
     @Mock private Timeouts.Adapter mTimeoutsAdapter;
@@ -88,13 +95,15 @@
         @Override
         public CompletionStage<CallFilteringResult> startFilterLookup(
                 CallFilteringResult priorStageResult) {
-            HandlerThread handlerThread = new HandlerThread("TimeoutFilter");
-            handlerThread.start();
-            Handler handler = new Handler(handlerThread.getLooper());
-
-            CompletableFuture<CallFilteringResult> resultFuture = new CompletableFuture<>();
-            handler.postDelayed(() -> resultFuture.complete(PASS_CALL_RESULT),
-                    TIMEOUT_FILTER_SLEEP_TIME);
+            Log.i(TAG, "TimeoutFilter: startFilterLookup: about to sleep");
+            try {
+                // Currently, there are no tools to fake a timeout with [CompletableFuture]s
+                // in the Android Platform. Thread sleep is the best option for an end-to-end test.
+                Thread.sleep(FILTER_TIMEOUT); // Simulate a filter timeout
+            } catch (InterruptedException e) {
+                e.printStackTrace();
+            }
+            Log.i(TAG, "TimeoutFilter: startFilterLookup: continuing test");
             return CompletableFuture.completedFuture(PASS_CALL_RESULT);
         }
     }
@@ -116,7 +125,7 @@
         CallFilterResultCallback listener = (call, result, timeout) -> testResult.complete(result);
 
         IncomingCallFilterGraph graph = new IncomingCallFilterGraph(mCall, listener, mContext,
-                mTimeoutsAdapter, mLock);
+                mTimeoutsAdapter, mFeatureFlags, mLock);
         graph.performFiltering();
 
         assertEquals(PASS_CALL_RESULT, testResult.get(TEST_TIMEOUT, TimeUnit.MILLISECONDS));
@@ -129,7 +138,7 @@
         CallFilterResultCallback listener = (call, result, timeout) -> testResult.complete(result);
 
         IncomingCallFilterGraph graph = new IncomingCallFilterGraph(mCall, listener, mContext,
-                mTimeoutsAdapter, mLock);
+                mTimeoutsAdapter, mFeatureFlags, mLock);
         AllowFilter allowFilter = new AllowFilter();
         DisallowFilter disallowFilter = new DisallowFilter();
         graph.addFilter(allowFilter);
@@ -147,7 +156,7 @@
         CallFilterResultCallback listener = (call, result, timeout) -> testResult.complete(result);
 
         IncomingCallFilterGraph graph = new IncomingCallFilterGraph(mCall, listener, mContext,
-                mTimeoutsAdapter, mLock);
+                mTimeoutsAdapter, mFeatureFlags, mLock);
         AllowFilter allowFilter1 = new AllowFilter();
         AllowFilter allowFilter2 = new AllowFilter();
         DisallowFilter disallowFilter = new DisallowFilter();
@@ -166,7 +175,7 @@
         CallFilterResultCallback listener = (call, result, timeout) -> testResult.complete(result);
 
         IncomingCallFilterGraph graph = new IncomingCallFilterGraph(mCall, listener, mContext,
-                mTimeoutsAdapter, mLock);
+                mTimeoutsAdapter, mFeatureFlags, mLock);
         DisallowFilter disallowFilter = new DisallowFilter();
         TimeoutFilter timeoutFilter = new TimeoutFilter();
         graph.addFilter(disallowFilter);
@@ -176,4 +185,57 @@
 
         assertEquals(REJECT_CALL_RESULT, testResult.get(TEST_TIMEOUT, TimeUnit.MILLISECONDS));
     }
+
+    /**
+     * Verify that when the Call Filtering Graph times out, already completed filters are combined.
+     * Graph being tested:
+     *
+     * startFilterLookup --> [ ALLOW_FILTER ]
+     *                            |
+     *         ---------------------------------
+     *        |                                |
+     *        |                                |
+     *    [DND_FILTER]                  [TIMEOUT_FILTER]
+     *        |                                |
+     *        |                        * timeout at 5 seconds *
+     *        |
+     *        |
+     *       --------[ CallFilteringResult ]
+     */
+    @SmallTest
+    @Test
+    public void testFilterTimesOutWithDndFilterComputedAlready() throws Exception {
+        // GIVEN: a graph that is set up like the above diagram in the test comment
+        Ringer mockRinger = mock(Ringer.class);
+        CompletableFuture<CallFilteringResult> testResult = new CompletableFuture<>();
+        IncomingCallFilterGraph graph = new IncomingCallFilterGraph(
+                mCall,
+                (call, result, timeout) -> testResult.complete(result),
+                mContext,
+                mTimeoutsAdapter,
+                mFeatureFlags,
+                mLock);
+        // create the filters / nodes  for the graph
+        TimeoutFilter timeoutFilter = new TimeoutFilter();
+        DndCallFilter dndCallFilter = new DndCallFilter(mCall, mockRinger);
+        AllowFilter allowFilter1 = new AllowFilter();
+        // adding them to the graph does not create the edges
+        graph.addFilter(allowFilter1);
+        graph.addFilter(timeoutFilter);
+        graph.addFilter(dndCallFilter);
+        // set up the graph so that the DND filter can process in parallel to the timeout
+        IncomingCallFilterGraph.addEdge(allowFilter1, dndCallFilter);
+        IncomingCallFilterGraph.addEdge(allowFilter1, timeoutFilter);
+
+        // WHEN:  DND is on and the caller cannot interrupt and the graph is processed
+        when(mockRinger.shouldRingForContact(mCall)).thenReturn(false);
+        when(mFeatureFlags.checkCompletedFiltersOnTimeout()).thenReturn(true);
+        dndCallFilter.startFilterLookup(IncomingCallFilterGraph.DEFAULT_RESULT);
+        graph.performFiltering();
+
+        // THEN: assert shouldSuppressCallDueToDndStatus is true!
+        assertFalse(IncomingCallFilterGraph.DEFAULT_RESULT.shouldSuppressCallDueToDndStatus);
+        assertTrue(testResult.get(TIMEOUT_FILTER_SLEEP_TIME,
+                TimeUnit.MILLISECONDS).shouldSuppressCallDueToDndStatus);
+    }
 }