Merge "Create WAP cache and API for retrieving WAP message sizes." into main
diff --git a/src/java/com/android/internal/telephony/SmsController.java b/src/java/com/android/internal/telephony/SmsController.java
index 777357d..251a1fd 100644
--- a/src/java/com/android/internal/telephony/SmsController.java
+++ b/src/java/com/android/internal/telephony/SmsController.java
@@ -1117,4 +1117,20 @@
         // Check if smscAddr is present in FDN list
         return FdnUtils.isNumberBlockedByFDN(phoneId, smscAddr, defaultCountryIso);
     }
+
+    /**
+     * Gets the message size of WAP from the cache.
+     *
+     * @param locationUrl the location to use as a key for looking up the size in the cache.
+     * The locationUrl may or may not have the transactionId appended to the url.
+     *
+     * @return long representing the message size
+     * @throws java.util.NoSuchElementException if the WAP push doesn't exist in the cache
+     * @throws IllegalArgumentException if the locationUrl is empty
+     */
+    @Override
+    public long getWapMessageSize(@NonNull String locationUrl) {
+        byte[] bytes = locationUrl.getBytes(StandardCharsets.ISO_8859_1);
+        return WapPushCache.getWapMessageSize(bytes);
+    }
 }
\ No newline at end of file
diff --git a/src/java/com/android/internal/telephony/WapPushCache.java b/src/java/com/android/internal/telephony/WapPushCache.java
new file mode 100644
index 0000000..70d12e2
--- /dev/null
+++ b/src/java/com/android/internal/telephony/WapPushCache.java
@@ -0,0 +1,172 @@
+/*
+ * 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;
+
+import android.annotation.NonNull;
+import android.telephony.Rlog;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+import java.nio.ByteBuffer;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.NoSuchElementException;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Caches WAP push PDU data for retrieval during MMS downloading.
+ * When on a satellite connection, the cached message size will be used to prevent downloading
+ * messages that exceed a threshold.
+ *
+ * The cache uses a circular buffer and will start invalidating the oldest entries after 250
+ * message sizes have been inserted.
+ * The cache also invalidates entries that have been in the cache for over 14 days.
+ */
+public class WapPushCache {
+    private static final String TAG = "WAP PUSH CACHE";
+
+    // Because we store each size twice, this represents 250 messages. That limit is chosen so
+    // that the memory footprint of the cache stays reasonably small while still supporting what
+    // we guess will be the vast majority of real use cases.
+    private static final int MAX_CACHE_SIZE = 500;
+
+    // WAP push PDUs have an expiry property, but we can't be certain that it is set accurately
+    // by the carrier. We will use our own expiry for this cache to keep it small. One example
+    // carrier has an expiry of 7 days so 14 will give us room for those with longer times as well.
+    private static final long CACHE_EXPIRY_TIME = TimeUnit.DAYS.toMillis(14);
+
+    private static final HashMap<String, CacheEntry> sMessageSizes = new LinkedHashMap<>() {
+        @Override
+        protected boolean removeEldestEntry(Entry<String, CacheEntry> eldest) {
+            return size() > MAX_CACHE_SIZE;
+        }
+    };
+
+    @VisibleForTesting
+    public static TelephonyFacade sTelephonyFacade = new TelephonyFacade();
+
+    /**
+     * Puts a WAP push PDU's messageSize in the cache.
+     *
+     * The data is stored twice, once using just locationUrl as the key and once
+     * using transactionId appended to the locationUrl. For some carriers, xMS apps
+     * append the transactionId to the location and we need to support lookup using either the
+     * original location or one modified in this way.
+
+     *
+     * @param locationUrl location of the message used as part of the cache key.
+     * @param transactionId message transaction ID used as part of the cache key.
+     * @param messageSize size of the message to be stored in the cache.
+     */
+    public static void putWapMessageSize(
+            @NonNull byte[] locationUrl,
+            @NonNull byte[] transactionId,
+            long messageSize
+    ) {
+        long expiry = sTelephonyFacade.getElapsedSinceBootMillis() + CACHE_EXPIRY_TIME;
+        if (messageSize <= 0) {
+            Rlog.e(TAG, "Invalid message size of " + messageSize + ". Not inserting.");
+            return;
+        }
+        synchronized (sMessageSizes) {
+            sMessageSizes.put(Arrays.toString(locationUrl), new CacheEntry(messageSize, expiry));
+
+            // concatenate the locationUrl and transactionId
+            byte[] joinedKey = ByteBuffer
+                    .allocate(locationUrl.length + transactionId.length)
+                    .put(locationUrl)
+                    .put(transactionId)
+                    .array();
+            sMessageSizes.put(Arrays.toString(joinedKey), new CacheEntry(messageSize, expiry));
+            invalidateOldEntries();
+        }
+    }
+
+    /**
+     * Remove entries from the cache that are older than CACHE_EXPIRY_TIME
+     */
+    private static void invalidateOldEntries() {
+        long currentTime = sTelephonyFacade.getElapsedSinceBootMillis();
+
+        // We can just remove elements from the start until one is found that does not exceed the
+        // expiry since the elements are in order of insertion.
+        for (Iterator<CacheEntry> it = sMessageSizes.values().iterator(); it.hasNext(); ) {
+            CacheEntry entry = it.next();
+            if (entry.mExpiry < currentTime) {
+                it.remove();
+            } else {
+                break;
+            }
+        }
+    }
+
+    /**
+     * Gets the message size of a WAP from the cache.
+     *
+     * Because we stored the size both using the location+transactionId key and using the
+     * location only key, we should be able to find the size whether the xMS app modified
+     * the location or not.
+     *
+     * @param locationUrl the location to use as a key for looking up the size in the cache.
+     *
+     * @return long representing the message size of the WAP
+     * @throws NoSuchElementException if the WAP doesn't exist in the cache
+     * @throws IllegalArgumentException if the locationUrl is empty
+     */
+    public static long getWapMessageSize(@NonNull byte[] locationUrl) {
+        if (locationUrl.length == 0) {
+            throw new IllegalArgumentException("Found empty locationUrl");
+        }
+        CacheEntry entry = sMessageSizes.get(Arrays.toString(locationUrl));
+        if (entry == null) {
+            throw new NoSuchElementException(
+                "No cached WAP size for locationUrl " + Arrays.toString(locationUrl)
+            );
+        }
+        return entry.mSize;
+    }
+
+    /**
+     * Clears all elements from the cache
+     */
+    @VisibleForTesting
+    public static void clear() {
+        sMessageSizes.clear();
+    }
+
+    /**
+     * Returns a count of the number of elements in the cache
+     * @return count of elements
+     */
+    @VisibleForTesting
+    public static int size() {
+        return sMessageSizes.size();
+    }
+
+
+
+    private static class CacheEntry {
+        CacheEntry(long size, long expiry) {
+            mSize = size;
+            mExpiry = expiry;
+        }
+        private final long mSize;
+        private final long mExpiry;
+    }
+}
diff --git a/src/java/com/android/internal/telephony/WapPushOverSms.java b/src/java/com/android/internal/telephony/WapPushOverSms.java
index d6ea4ad..7669411 100644
--- a/src/java/com/android/internal/telephony/WapPushOverSms.java
+++ b/src/java/com/android/internal/telephony/WapPushOverSms.java
@@ -262,6 +262,13 @@
 
             if (parsedPdu != null && parsedPdu.getMessageType() == MESSAGE_TYPE_NOTIFICATION_IND) {
                 final NotificationInd nInd = (NotificationInd) parsedPdu;
+                // save the WAP push message size so that if a download request is made for it
+                // while on a satellite connection we can check if the size is under the threshold
+                WapPushCache.putWapMessageSize(
+                        nInd.getContentLocation(),
+                        nInd.getTransactionId(),
+                        nInd.getMessageSize()
+                );
                 if (nInd.getFrom() != null
                         && BlockChecker.isBlocked(mContext, nInd.getFrom().getString(), null)) {
                     result.statusCode = Intents.RESULT_SMS_HANDLED;
diff --git a/tests/telephonytests/src/com/android/internal/telephony/SmsControllerTest.java b/tests/telephonytests/src/com/android/internal/telephony/SmsControllerTest.java
index 8180bc0..972df5d 100644
--- a/tests/telephonytests/src/com/android/internal/telephony/SmsControllerTest.java
+++ b/tests/telephonytests/src/com/android/internal/telephony/SmsControllerTest.java
@@ -16,7 +16,10 @@
 
 package com.android.internal.telephony;
 
+import static junit.framework.Assert.assertEquals;
+
 import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertThrows;
 import static org.junit.Assert.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.anyInt;
@@ -40,7 +43,9 @@
 import org.junit.runner.RunWith;
 import org.mockito.Mockito;
 
+import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
+import java.util.NoSuchElementException;
 
 @RunWith(AndroidTestingRunner.class)
 @TestableLooper.RunWithLooper
@@ -65,6 +70,7 @@
     @After
     public void tearDown() throws Exception {
         mAdnRecordCache = null;
+        WapPushCache.clear();
         super.tearDown();
     }
 
@@ -239,4 +245,40 @@
         verify(mIccSmsInterfaceManager, Mockito.times(0))
                 .sendText(mCallingPackage, "1234", null, "text", null, null, false, 0L, true);
     }
+
+    @Test
+    public void testGetWapMessageSize() {
+        long expectedSize = 100L;
+        String location = "content://mms";
+        byte[] locationBytes = location.getBytes(StandardCharsets.ISO_8859_1);
+        byte[] transactionId = "123".getBytes(StandardCharsets.ISO_8859_1);
+
+        WapPushCache.putWapMessageSize(locationBytes, transactionId, expectedSize);
+        long size = mSmsControllerUT.getWapMessageSize(location);
+
+        assertEquals(expectedSize, size);
+    }
+
+    @Test
+    public void testGetWapMessageSize_withTransactionIdAppended() {
+        long expectedSize = 100L;
+        byte[] location = "content://mms".getBytes(StandardCharsets.ISO_8859_1);
+        byte[] transactionId = "123".getBytes(StandardCharsets.ISO_8859_1);
+        byte[] joinedKey = new byte[location.length + transactionId.length];
+        System.arraycopy(location, 0, joinedKey, 0, location.length);
+        System.arraycopy(transactionId, 0, joinedKey, location.length, transactionId.length);
+        String joinedKeyString = new String(joinedKey, StandardCharsets.ISO_8859_1);
+
+        WapPushCache.putWapMessageSize(location, transactionId, expectedSize);
+        long size = mSmsControllerUT.getWapMessageSize(joinedKeyString);
+
+        assertEquals(expectedSize, size);
+    }
+
+    @Test
+    public void testGetWapMessageSize_nonexistentThrows() {
+        assertThrows(NoSuchElementException.class, () ->
+                mSmsControllerUT.getWapMessageSize("content://mms")
+        );
+    }
 }
\ No newline at end of file
diff --git a/tests/telephonytests/src/com/android/internal/telephony/WapPushCacheTest.java b/tests/telephonytests/src/com/android/internal/telephony/WapPushCacheTest.java
new file mode 100644
index 0000000..f572c08
--- /dev/null
+++ b/tests/telephonytests/src/com/android/internal/telephony/WapPushCacheTest.java
@@ -0,0 +1,144 @@
+/*
+ * 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;
+
+import static junit.framework.Assert.assertEquals;
+
+import static org.junit.Assert.assertThrows;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.time.Clock;
+import java.util.NoSuchElementException;
+import java.util.concurrent.TimeUnit;
+
+public class WapPushCacheTest extends TelephonyTest {
+
+    @Before
+    public void setUp() throws Exception {
+        super.setUp(getClass().getSimpleName());
+    }
+
+    @After
+    public void tearDown() throws Exception {
+        WapPushCache.clear();
+        WapPushCache.sTelephonyFacade = new TelephonyFacade();
+        super.tearDown();
+    }
+
+    @Test
+    public void testGetWapMessageSize() {
+        long expectedSize = 100L;
+        byte[] location = "content://mms".getBytes();
+        byte[] transactionId = "123".getBytes();
+
+        WapPushCache.putWapMessageSize(location, transactionId, expectedSize);
+        long size = WapPushCache.getWapMessageSize(location);
+
+        assertEquals(expectedSize, size);
+    }
+
+    @Test
+    public void testGetWapMessageSize_withTransactionIdAppended() {
+        long expectedSize = 100L;
+        byte[] location = "content://mms".getBytes();
+        byte[] transactionId = "123".getBytes();
+        byte[] joinedKey = new byte[location.length + transactionId.length];
+        System.arraycopy(location, 0, joinedKey, 0, location.length);
+        System.arraycopy(transactionId, 0, joinedKey, location.length, transactionId.length);
+
+        WapPushCache.putWapMessageSize(location, transactionId, expectedSize);
+        long size = WapPushCache.getWapMessageSize(joinedKey);
+
+        assertEquals(expectedSize, size);
+    }
+
+    @Test
+    public void testGetWapMessageSize_nonexistentThrows() {
+        assertThrows(NoSuchElementException.class, () ->
+                WapPushCache.getWapMessageSize("content://mms".getBytes())
+        );
+    }
+    @Test
+    public void testGetWapMessageSize_emptyLocationUrlThrows() {
+        assertThrows(IllegalArgumentException.class, () ->
+                WapPushCache.getWapMessageSize(new byte[0])
+        );
+    }
+
+    @Test
+    public void testPutWapMessageSize_invalidValuePreventsInsert() {
+        long expectedSize = 0L;
+        byte[] location = "content://mms".getBytes();
+        byte[] transactionId = "123".getBytes();
+
+        WapPushCache.putWapMessageSize(location, transactionId, expectedSize);
+
+        assertEquals(0, WapPushCache.size());
+    }
+
+    @Test
+    public void testPutWapMessageSize_sizeLimitExceeded_oldestEntryRemoved() {
+        long expectedSize = 100L;
+        for (int i = 0; i < 251; i++) {
+            byte[] location = ("" + i).getBytes();
+            byte[] transactionId = "abc".getBytes();
+            WapPushCache.putWapMessageSize(location, transactionId, expectedSize);
+        }
+
+        // assert one of the entries inserted above has been removed
+        assertEquals(500, WapPushCache.size());
+        // assert last entry added exists
+        assertEquals(expectedSize, WapPushCache.getWapMessageSize("250".getBytes()));
+        // assert the first entry added was removed
+        assertThrows(NoSuchElementException.class, () ->
+                WapPushCache.getWapMessageSize("0".getBytes())
+        );
+    }
+
+    @Test
+    public void testPutWapMessageSize_expiryExceeded_entryRemoved() {
+        long currentTime = Clock.systemUTC().millis();
+        TelephonyFacade facade = mock(TelephonyFacade.class);
+        when(facade.getElapsedSinceBootMillis()).thenReturn(currentTime);
+        WapPushCache.sTelephonyFacade = facade;
+
+        long expectedSize = 100L;
+        byte[] transactionId = "abc".getBytes();
+        byte[] location1 = "old".getBytes();
+        byte[] location2 = "new".getBytes();
+
+        WapPushCache.putWapMessageSize(location1, transactionId, expectedSize);
+        assertEquals(2, WapPushCache.size());
+
+        // advance time
+        when(facade.getElapsedSinceBootMillis())
+                .thenReturn(currentTime + TimeUnit.DAYS.toMillis(14) + 1);
+
+        WapPushCache.putWapMessageSize(location2, transactionId, expectedSize);
+
+        assertEquals(2, WapPushCache.size());
+        assertEquals(expectedSize, WapPushCache.getWapMessageSize(location2));
+        assertThrows(NoSuchElementException.class, () ->
+                WapPushCache.getWapMessageSize(location1)
+        );
+    }
+}
diff --git a/tests/telephonytests/src/com/android/internal/telephony/WapPushOverSmsTest.java b/tests/telephonytests/src/com/android/internal/telephony/WapPushOverSmsTest.java
index 8e40271..f5d4e95 100644
--- a/tests/telephonytests/src/com/android/internal/telephony/WapPushOverSmsTest.java
+++ b/tests/telephonytests/src/com/android/internal/telephony/WapPushOverSmsTest.java
@@ -62,6 +62,7 @@
 
     @After
     public void tearDown() throws Exception {
+        WapPushCache.clear();
         mWapPushOverSmsUT = null;
         super.tearDown();
     }
@@ -150,4 +151,29 @@
                 any(UserHandle.class),
                 anyInt());
     }
+
+    @Test @SmallTest
+    public void testDispatchWapPdu_notificationIndInsertedToCache() throws Exception {
+        assertEquals(0, WapPushCache.size());
+        when(mISmsStub.getCarrierConfigValuesForSubscriber(anyInt())).thenReturn(new Bundle());
+
+        doReturn(true).when(mWspTypeDecoder).decodeUintvarInteger(anyInt());
+        doReturn(true).when(mWspTypeDecoder).decodeContentType(anyInt());
+        doReturn((long) 2).when(mWspTypeDecoder).getValue32();
+        doReturn(2).when(mWspTypeDecoder).getDecodedDataLength();
+        doReturn(WspTypeDecoder.CONTENT_TYPE_B_PUSH_CO).when(mWspTypeDecoder).getValueString();
+
+        byte[] pdu = {1, 6, 0, 97, 112, 112, 108, 105, 99, 97, 116, 105, 111, 110, 47,
+                118, 110, 100, 46, 119, 97, 112, 46, 109, 109, 115, 45, 109, 101, 115, 115,
+                97, 103, 101, 0, -116, -126, -104, 77, 109, 115, 84, 114, 97, 110, 115, 97,
+                99, 116, 105, 111, 110, 73, 68, 0, -115, 18, -119, 8, -128, 49, 54, 49, 55,
+                56, 50, 54, 57, 49, 54, 56, 47, 84, 89, 80, 69, 61, 80, 76, 77, 78, 0, -118,
+                -128, -114, 2, 3, -24, -120, 3, -127, 3, 3, -12, -128, -106, 84, 101, 115,
+                116, 32, 77, 109, 115, 32, 83, 117, 98, 106, 101, 99, 116, 0, -125, 104, 116,
+                116, 112, 58, 47, 47, 119, 119, 119, 46, 103, 111, 111, 103, 108, 101, 46, 99,
+                111, 109, 47, 115, 97, 100, 102, 100, 100, 0};
+
+        mWapPushOverSmsUT.dispatchWapPdu(pdu, null, mInboundSmsHandler, null, 0, 0L);
+        assertEquals(2, WapPushCache.size());
+    }
 }