Loading src/java/com/android/internal/telephony/security/CellularIdentifierDisclosureNotifier.java +165 −20 Original line number Diff line number Diff line Loading @@ -18,47 +18,101 @@ package com.android.internal.telephony.security; import android.telephony.CellularIdentifierDisclosure; import com.android.internal.annotations.GuardedBy; import com.android.internal.annotations.VisibleForTesting; import com.android.telephony.Rlog; import java.util.concurrent.Executors; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; /** * Encapsulates logic to emit notifications to the user that their cellular identifiers were * disclosed in the clear. * disclosed in the clear. Callers add CellularIdentifierDisclosure instances by calling * addDisclosure. * * <p>This class will either emit notifications through SafetyCenterManager if SafetyCenter exists * on a device, or it will emit system notifications otherwise. * <p>This class is thread safe and is designed to do costly work on worker threads. The intention * is to allow callers to add disclosures from a Looper thread without worrying about blocking for * IPC. * * @hide */ public class CellularIdentifierDisclosureNotifier { private static final String TAG = "CellularIdentifierDisclosureNotifier"; private static final long DEFAULT_WINDOW_CLOSE_DURATION_IN_MINUTES = 15; private static CellularIdentifierDisclosureNotifier sInstance = null; private final long mWindowCloseDuration; private final TimeUnit mWindowCloseUnit; private final Object mEnabledLock = new Object(); @GuardedBy("mEnabledLock") private boolean mEnabled = false; // This is a single threaded executor. This is important because we want to ensure certain // events are strictly serialized. private ScheduledExecutorService mSerializedWorkQueue; private AtomicInteger mDisclosureCount; // One should only interact with this future from within the work queue's thread. private ScheduledFuture<?> mWhenWindowCloses; public CellularIdentifierDisclosureNotifier() { this(Executors.newSingleThreadScheduledExecutor(), DEFAULT_WINDOW_CLOSE_DURATION_IN_MINUTES, TimeUnit.MINUTES); } /** * Construct a CellularIdentifierDisclosureNotifier by injection. This should only be used for * testing. * * @param notificationQueue a ScheduledExecutorService that should only execute on a single * thread. */ @VisibleForTesting public CellularIdentifierDisclosureNotifier() {} public CellularIdentifierDisclosureNotifier( ScheduledExecutorService notificationQueue, long windowCloseDuration, TimeUnit windowCloseUnit) { mSerializedWorkQueue = notificationQueue; mWindowCloseDuration = windowCloseDuration; mWindowCloseUnit = windowCloseUnit; mDisclosureCount = new AtomicInteger(0); } /** * Add a CellularIdentifierDisclosure to be tracked by this instance. * If appropriate, this will trigger a user notification. */ public void addDisclosure(CellularIdentifierDisclosure disclosure) { // TODO (b/308985417) this is a stub method for now. Logic // for tracking disclosures and emitting notifications will flow // from here. Rlog.d(TAG, "Identifier disclosure reported: " + disclosure); synchronized (mEnabledLock) { if (!mEnabled) { Rlog.d(TAG, "Skipping disclosure because notifier was disabled."); return; } /** * Get a singleton CellularIdentifierDisclosureNotifier. */ public static synchronized CellularIdentifierDisclosureNotifier getInstance() { if (sInstance == null) { sInstance = new CellularIdentifierDisclosureNotifier(); // Don't notify if this disclosure happened in service of an emergency. That's a user // initiated action that we don't want to interfere with. if (disclosure.isEmergency()) { Rlog.i(TAG, "Ignoring identifier disclosure associated with an emergency."); return; } return sInstance; // Schedule incrementAndNotify from within the lock because we're sure at this point // that we're enabled. This allows incrementAndNotify to avoid re-checking mEnabled // because we know that any actions taken on disabled will be scheduled after this // incrementAndNotify call. try { mSerializedWorkQueue.execute(incrementAndNotify()); } catch (RejectedExecutionException e) { Rlog.e(TAG, "Failed to schedule incrementAndNotify: " + e.getMessage()); } } // end mEnabledLock } /** Loading @@ -66,8 +120,15 @@ public class CellularIdentifierDisclosureNotifier { * disclosures again and potentially emitting notifications. */ public void enable() { synchronized (mEnabledLock) { Rlog.d(TAG, "enabled"); mEnabled = true; try { mSerializedWorkQueue.execute(onEnableNotifier()); } catch (RejectedExecutionException e) { Rlog.e(TAG, "Failed to schedule onEnableNotifier: " + e.getMessage()); } } } /** Loading @@ -77,10 +138,94 @@ public class CellularIdentifierDisclosureNotifier { */ public void disable() { Rlog.d(TAG, "disabled"); synchronized (mEnabledLock) { mEnabled = false; try { mSerializedWorkQueue.execute(onDisableNotifier()); } catch (RejectedExecutionException e) { Rlog.e(TAG, "Failed to schedule onDisableNotifier: " + e.getMessage()); } } } public boolean isEnabled() { synchronized (mEnabledLock) { return mEnabled; } } @VisibleForTesting public int getCurrentDisclosureCount() { return mDisclosureCount.get(); } /** Get a singleton CellularIdentifierDisclosureNotifier. */ public static synchronized CellularIdentifierDisclosureNotifier getInstance() { if (sInstance == null) { sInstance = new CellularIdentifierDisclosureNotifier(); } return sInstance; } private Runnable closeWindow() { return () -> { Rlog.i(TAG, "Disclosure window closing. Disclosure count was " + mDisclosureCount.get()); mDisclosureCount.set(0); }; } private Runnable incrementAndNotify() { return () -> { int newCount = mDisclosureCount.incrementAndGet(); Rlog.d(TAG, "Emitting notification. New disclosure count " + newCount); // To reset the timer for our window, we first cancel an existing timer. boolean cancelled = cancelWindowCloseFuture(); Rlog.d(TAG, "Result of attempting to cancel window closing future: " + cancelled); try { mWhenWindowCloses = mSerializedWorkQueue.schedule( closeWindow(), mWindowCloseDuration, mWindowCloseUnit); } catch (RejectedExecutionException e) { Rlog.e(TAG, "Failed to schedule closeWindow: " + e.getMessage()); } }; } private Runnable onDisableNotifier() { return () -> { mDisclosureCount.set(0); cancelWindowCloseFuture(); Rlog.d(TAG, "On disable notifier"); }; } private Runnable onEnableNotifier() { return () -> { Rlog.i(TAG, "On enable notifier"); }; } /** * A helper to cancel the Future that is in charge of closing the disclosure window. This must * only be called from within the single-threaded executor. Calling this method leaves a * completed or cancelled future in mWhenWindowCloses. * * @return boolean indicating whether or not the Future was actually cancelled. If false, this * likely indicates that the disclosure window has already closed. */ private boolean cancelWindowCloseFuture() { if (mWhenWindowCloses == null) { return false; } // While we choose not to interrupt a running Future (we pass `false` to the `cancel` // call), we shouldn't ever actually need this functionality because all the work on the // queue is serialized on a single thread. Nothing about the `closeWindow` call is ready // to handle interrupts, though, so this seems like a safer choice. return mWhenWindowCloses.cancel(false); } } tests/telephonytests/src/com/android/internal/telephony/security/CellularIdentifierDisclosureNotifierTest.java 0 → 100644 +140 −0 Original line number Diff line number Diff line /* * Copyright (C) 2023 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.internal.telephony.security; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import android.telephony.CellularIdentifierDisclosure; import com.android.internal.telephony.TestExecutorService; import org.junit.Before; import org.junit.Test; import java.util.concurrent.TimeUnit; public class CellularIdentifierDisclosureNotifierTest { // 15 minutes and 100 milliseconds. Can be used to advance time in a test executor far enough // to (hopefully, if the code is behaving) close a disclosure window. private static final long WINDOW_CLOSE_ADVANCE_MILLIS = (15 * 60 * 1000) + 100; private CellularIdentifierDisclosure mDislosure; @Before public void setUp() { mDislosure = new CellularIdentifierDisclosure( CellularIdentifierDisclosure.NAS_PROTOCOL_MESSAGE_ATTACH_REQUEST, CellularIdentifierDisclosure.CELLULAR_IDENTIFIER_IMSI, "001001", false); } @Test public void testInitializeDisabled() { TestExecutorService executor = new TestExecutorService(); CellularIdentifierDisclosureNotifier notifier = new CellularIdentifierDisclosureNotifier(executor, 15, TimeUnit.MINUTES); assertFalse(notifier.isEnabled()); } @Test public void testDisableAddDisclosureNop() { TestExecutorService executor = new TestExecutorService(); CellularIdentifierDisclosureNotifier notifier = new CellularIdentifierDisclosureNotifier(executor, 15, TimeUnit.MINUTES); assertFalse(notifier.isEnabled()); notifier.addDisclosure(mDislosure); assertEquals(0, notifier.getCurrentDisclosureCount()); } @Test public void testAddDisclosureEmergencyNop() { TestExecutorService executor = new TestExecutorService(); CellularIdentifierDisclosureNotifier notifier = new CellularIdentifierDisclosureNotifier(executor, 15, TimeUnit.MINUTES); CellularIdentifierDisclosure emergencyDisclosure = new CellularIdentifierDisclosure( CellularIdentifierDisclosure.NAS_PROTOCOL_MESSAGE_ATTACH_REQUEST, CellularIdentifierDisclosure.CELLULAR_IDENTIFIER_IMSI, "001001", true); notifier.enable(); notifier.addDisclosure(emergencyDisclosure); assertEquals(0, notifier.getCurrentDisclosureCount()); } @Test public void testAddDisclosureCountIncrements() { TestExecutorService executor = new TestExecutorService(); CellularIdentifierDisclosureNotifier notifier = new CellularIdentifierDisclosureNotifier(executor, 15, TimeUnit.MINUTES); notifier.enable(); notifier.addDisclosure(mDislosure); notifier.addDisclosure(mDislosure); notifier.addDisclosure(mDislosure); assertEquals(3, notifier.getCurrentDisclosureCount()); } @Test public void testAddDisclosureThenWindowClose() { TestExecutorService executor = new TestExecutorService(); CellularIdentifierDisclosureNotifier notifier = new CellularIdentifierDisclosureNotifier(executor, 15, TimeUnit.MINUTES); // One round of disclosures notifier.enable(); notifier.addDisclosure(mDislosure); notifier.addDisclosure(mDislosure); assertEquals(2, notifier.getCurrentDisclosureCount()); // Window close should reset the counter executor.advanceTime(WINDOW_CLOSE_ADVANCE_MILLIS); assertEquals(0, notifier.getCurrentDisclosureCount()); // A new disclosure should increment as normal notifier.addDisclosure(mDislosure); assertEquals(1, notifier.getCurrentDisclosureCount()); } @Test public void testDisableClosesWindow() { TestExecutorService executor = new TestExecutorService(); CellularIdentifierDisclosureNotifier notifier = new CellularIdentifierDisclosureNotifier(executor, 15, TimeUnit.MINUTES); // One round of disclosures notifier.enable(); notifier.addDisclosure(mDislosure); notifier.addDisclosure(mDislosure); assertEquals(2, notifier.getCurrentDisclosureCount()); notifier.disable(); assertFalse(notifier.isEnabled()); // We're disabled now so no disclosures should open the disclosure window notifier.addDisclosure(mDislosure); assertEquals(0, notifier.getCurrentDisclosureCount()); } } Loading
src/java/com/android/internal/telephony/security/CellularIdentifierDisclosureNotifier.java +165 −20 Original line number Diff line number Diff line Loading @@ -18,47 +18,101 @@ package com.android.internal.telephony.security; import android.telephony.CellularIdentifierDisclosure; import com.android.internal.annotations.GuardedBy; import com.android.internal.annotations.VisibleForTesting; import com.android.telephony.Rlog; import java.util.concurrent.Executors; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; /** * Encapsulates logic to emit notifications to the user that their cellular identifiers were * disclosed in the clear. * disclosed in the clear. Callers add CellularIdentifierDisclosure instances by calling * addDisclosure. * * <p>This class will either emit notifications through SafetyCenterManager if SafetyCenter exists * on a device, or it will emit system notifications otherwise. * <p>This class is thread safe and is designed to do costly work on worker threads. The intention * is to allow callers to add disclosures from a Looper thread without worrying about blocking for * IPC. * * @hide */ public class CellularIdentifierDisclosureNotifier { private static final String TAG = "CellularIdentifierDisclosureNotifier"; private static final long DEFAULT_WINDOW_CLOSE_DURATION_IN_MINUTES = 15; private static CellularIdentifierDisclosureNotifier sInstance = null; private final long mWindowCloseDuration; private final TimeUnit mWindowCloseUnit; private final Object mEnabledLock = new Object(); @GuardedBy("mEnabledLock") private boolean mEnabled = false; // This is a single threaded executor. This is important because we want to ensure certain // events are strictly serialized. private ScheduledExecutorService mSerializedWorkQueue; private AtomicInteger mDisclosureCount; // One should only interact with this future from within the work queue's thread. private ScheduledFuture<?> mWhenWindowCloses; public CellularIdentifierDisclosureNotifier() { this(Executors.newSingleThreadScheduledExecutor(), DEFAULT_WINDOW_CLOSE_DURATION_IN_MINUTES, TimeUnit.MINUTES); } /** * Construct a CellularIdentifierDisclosureNotifier by injection. This should only be used for * testing. * * @param notificationQueue a ScheduledExecutorService that should only execute on a single * thread. */ @VisibleForTesting public CellularIdentifierDisclosureNotifier() {} public CellularIdentifierDisclosureNotifier( ScheduledExecutorService notificationQueue, long windowCloseDuration, TimeUnit windowCloseUnit) { mSerializedWorkQueue = notificationQueue; mWindowCloseDuration = windowCloseDuration; mWindowCloseUnit = windowCloseUnit; mDisclosureCount = new AtomicInteger(0); } /** * Add a CellularIdentifierDisclosure to be tracked by this instance. * If appropriate, this will trigger a user notification. */ public void addDisclosure(CellularIdentifierDisclosure disclosure) { // TODO (b/308985417) this is a stub method for now. Logic // for tracking disclosures and emitting notifications will flow // from here. Rlog.d(TAG, "Identifier disclosure reported: " + disclosure); synchronized (mEnabledLock) { if (!mEnabled) { Rlog.d(TAG, "Skipping disclosure because notifier was disabled."); return; } /** * Get a singleton CellularIdentifierDisclosureNotifier. */ public static synchronized CellularIdentifierDisclosureNotifier getInstance() { if (sInstance == null) { sInstance = new CellularIdentifierDisclosureNotifier(); // Don't notify if this disclosure happened in service of an emergency. That's a user // initiated action that we don't want to interfere with. if (disclosure.isEmergency()) { Rlog.i(TAG, "Ignoring identifier disclosure associated with an emergency."); return; } return sInstance; // Schedule incrementAndNotify from within the lock because we're sure at this point // that we're enabled. This allows incrementAndNotify to avoid re-checking mEnabled // because we know that any actions taken on disabled will be scheduled after this // incrementAndNotify call. try { mSerializedWorkQueue.execute(incrementAndNotify()); } catch (RejectedExecutionException e) { Rlog.e(TAG, "Failed to schedule incrementAndNotify: " + e.getMessage()); } } // end mEnabledLock } /** Loading @@ -66,8 +120,15 @@ public class CellularIdentifierDisclosureNotifier { * disclosures again and potentially emitting notifications. */ public void enable() { synchronized (mEnabledLock) { Rlog.d(TAG, "enabled"); mEnabled = true; try { mSerializedWorkQueue.execute(onEnableNotifier()); } catch (RejectedExecutionException e) { Rlog.e(TAG, "Failed to schedule onEnableNotifier: " + e.getMessage()); } } } /** Loading @@ -77,10 +138,94 @@ public class CellularIdentifierDisclosureNotifier { */ public void disable() { Rlog.d(TAG, "disabled"); synchronized (mEnabledLock) { mEnabled = false; try { mSerializedWorkQueue.execute(onDisableNotifier()); } catch (RejectedExecutionException e) { Rlog.e(TAG, "Failed to schedule onDisableNotifier: " + e.getMessage()); } } } public boolean isEnabled() { synchronized (mEnabledLock) { return mEnabled; } } @VisibleForTesting public int getCurrentDisclosureCount() { return mDisclosureCount.get(); } /** Get a singleton CellularIdentifierDisclosureNotifier. */ public static synchronized CellularIdentifierDisclosureNotifier getInstance() { if (sInstance == null) { sInstance = new CellularIdentifierDisclosureNotifier(); } return sInstance; } private Runnable closeWindow() { return () -> { Rlog.i(TAG, "Disclosure window closing. Disclosure count was " + mDisclosureCount.get()); mDisclosureCount.set(0); }; } private Runnable incrementAndNotify() { return () -> { int newCount = mDisclosureCount.incrementAndGet(); Rlog.d(TAG, "Emitting notification. New disclosure count " + newCount); // To reset the timer for our window, we first cancel an existing timer. boolean cancelled = cancelWindowCloseFuture(); Rlog.d(TAG, "Result of attempting to cancel window closing future: " + cancelled); try { mWhenWindowCloses = mSerializedWorkQueue.schedule( closeWindow(), mWindowCloseDuration, mWindowCloseUnit); } catch (RejectedExecutionException e) { Rlog.e(TAG, "Failed to schedule closeWindow: " + e.getMessage()); } }; } private Runnable onDisableNotifier() { return () -> { mDisclosureCount.set(0); cancelWindowCloseFuture(); Rlog.d(TAG, "On disable notifier"); }; } private Runnable onEnableNotifier() { return () -> { Rlog.i(TAG, "On enable notifier"); }; } /** * A helper to cancel the Future that is in charge of closing the disclosure window. This must * only be called from within the single-threaded executor. Calling this method leaves a * completed or cancelled future in mWhenWindowCloses. * * @return boolean indicating whether or not the Future was actually cancelled. If false, this * likely indicates that the disclosure window has already closed. */ private boolean cancelWindowCloseFuture() { if (mWhenWindowCloses == null) { return false; } // While we choose not to interrupt a running Future (we pass `false` to the `cancel` // call), we shouldn't ever actually need this functionality because all the work on the // queue is serialized on a single thread. Nothing about the `closeWindow` call is ready // to handle interrupts, though, so this seems like a safer choice. return mWhenWindowCloses.cancel(false); } }
tests/telephonytests/src/com/android/internal/telephony/security/CellularIdentifierDisclosureNotifierTest.java 0 → 100644 +140 −0 Original line number Diff line number Diff line /* * Copyright (C) 2023 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.internal.telephony.security; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import android.telephony.CellularIdentifierDisclosure; import com.android.internal.telephony.TestExecutorService; import org.junit.Before; import org.junit.Test; import java.util.concurrent.TimeUnit; public class CellularIdentifierDisclosureNotifierTest { // 15 minutes and 100 milliseconds. Can be used to advance time in a test executor far enough // to (hopefully, if the code is behaving) close a disclosure window. private static final long WINDOW_CLOSE_ADVANCE_MILLIS = (15 * 60 * 1000) + 100; private CellularIdentifierDisclosure mDislosure; @Before public void setUp() { mDislosure = new CellularIdentifierDisclosure( CellularIdentifierDisclosure.NAS_PROTOCOL_MESSAGE_ATTACH_REQUEST, CellularIdentifierDisclosure.CELLULAR_IDENTIFIER_IMSI, "001001", false); } @Test public void testInitializeDisabled() { TestExecutorService executor = new TestExecutorService(); CellularIdentifierDisclosureNotifier notifier = new CellularIdentifierDisclosureNotifier(executor, 15, TimeUnit.MINUTES); assertFalse(notifier.isEnabled()); } @Test public void testDisableAddDisclosureNop() { TestExecutorService executor = new TestExecutorService(); CellularIdentifierDisclosureNotifier notifier = new CellularIdentifierDisclosureNotifier(executor, 15, TimeUnit.MINUTES); assertFalse(notifier.isEnabled()); notifier.addDisclosure(mDislosure); assertEquals(0, notifier.getCurrentDisclosureCount()); } @Test public void testAddDisclosureEmergencyNop() { TestExecutorService executor = new TestExecutorService(); CellularIdentifierDisclosureNotifier notifier = new CellularIdentifierDisclosureNotifier(executor, 15, TimeUnit.MINUTES); CellularIdentifierDisclosure emergencyDisclosure = new CellularIdentifierDisclosure( CellularIdentifierDisclosure.NAS_PROTOCOL_MESSAGE_ATTACH_REQUEST, CellularIdentifierDisclosure.CELLULAR_IDENTIFIER_IMSI, "001001", true); notifier.enable(); notifier.addDisclosure(emergencyDisclosure); assertEquals(0, notifier.getCurrentDisclosureCount()); } @Test public void testAddDisclosureCountIncrements() { TestExecutorService executor = new TestExecutorService(); CellularIdentifierDisclosureNotifier notifier = new CellularIdentifierDisclosureNotifier(executor, 15, TimeUnit.MINUTES); notifier.enable(); notifier.addDisclosure(mDislosure); notifier.addDisclosure(mDislosure); notifier.addDisclosure(mDislosure); assertEquals(3, notifier.getCurrentDisclosureCount()); } @Test public void testAddDisclosureThenWindowClose() { TestExecutorService executor = new TestExecutorService(); CellularIdentifierDisclosureNotifier notifier = new CellularIdentifierDisclosureNotifier(executor, 15, TimeUnit.MINUTES); // One round of disclosures notifier.enable(); notifier.addDisclosure(mDislosure); notifier.addDisclosure(mDislosure); assertEquals(2, notifier.getCurrentDisclosureCount()); // Window close should reset the counter executor.advanceTime(WINDOW_CLOSE_ADVANCE_MILLIS); assertEquals(0, notifier.getCurrentDisclosureCount()); // A new disclosure should increment as normal notifier.addDisclosure(mDislosure); assertEquals(1, notifier.getCurrentDisclosureCount()); } @Test public void testDisableClosesWindow() { TestExecutorService executor = new TestExecutorService(); CellularIdentifierDisclosureNotifier notifier = new CellularIdentifierDisclosureNotifier(executor, 15, TimeUnit.MINUTES); // One round of disclosures notifier.enable(); notifier.addDisclosure(mDislosure); notifier.addDisclosure(mDislosure); assertEquals(2, notifier.getCurrentDisclosureCount()); notifier.disable(); assertFalse(notifier.isEnabled()); // We're disabled now so no disclosures should open the disclosure window notifier.addDisclosure(mDislosure); assertEquals(0, notifier.getCurrentDisclosureCount()); } }