/* * Copyright (C) 2022 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.mms.service.metrics; import android.content.Context; import android.content.pm.PackageManager; import android.os.Build; import android.os.Handler; import android.os.HandlerThread; import android.util.Log; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import com.android.mms.IncomingMms; import com.android.mms.OutgoingMms; import com.android.mms.PersistMmsAtoms; import java.io.FileOutputStream; import java.io.IOException; import java.nio.file.Files; import java.nio.file.NoSuchFileException; import java.security.SecureRandom; import java.util.ArrayList; import java.util.Collections; import java.util.List; public class PersistMmsAtomsStorage { private static final String TAG = PersistMmsAtomsStorage.class.getSimpleName(); /** Name of the file where cached statistics are saved to. */ private static final String FILENAME = "persist_mms_atoms.pb"; /** Delay to store atoms to persistent storage to bundle multiple operations together. */ private static final int SAVE_TO_FILE_DELAY_FOR_UPDATE_MILLIS = 30000; /** * Delay to store atoms to persistent storage during pulls to avoid unnecessary operations. * *

This delay should be short to avoid duplicating atoms or losing pull timestamp in case of * crash or power loss. */ private static final int SAVE_TO_FILE_DELAY_FOR_GET_MILLIS = 500; private static final SecureRandom sRandom = new SecureRandom(); /** * Maximum number of MMS to store between pulls. * Incoming MMS and outgoing MMS are counted separately. */ private final int mMaxNumMms; private final Context mContext; private final Handler mHandler; private final HandlerThread mHandlerThread; /** Stores persist atoms and persist states of the puller. */ @VisibleForTesting protected PersistMmsAtoms mPersistMmsAtoms; private final Runnable mSaveRunnable = new Runnable() { @Override public void run() { saveAtomsToFileNow(); } }; /** Whether atoms should be saved immediately, skipping the delay. */ @VisibleForTesting protected boolean mSaveImmediately; public PersistMmsAtomsStorage(Context context) { mContext = context; if (mContext.getPackageManager().hasSystemFeature(PackageManager.FEATURE_RAM_LOW)) { Log.i(TAG, "[PersistMmsAtomsStorage]: Low RAM device"); mMaxNumMms = 5; } else { mMaxNumMms = 25; } mPersistMmsAtoms = loadAtomsFromFile(); mHandlerThread = new HandlerThread("PersistMmsAtomsThread"); mHandlerThread.start(); mHandler = new Handler(mHandlerThread.getLooper()); mSaveImmediately = false; } /** Loads {@link PersistMmsAtoms} from a file in private storage. */ private PersistMmsAtoms loadAtomsFromFile() { try { PersistMmsAtoms atoms = PersistMmsAtoms.parseFrom( Files.readAllBytes(mContext.getFileStreamPath(FILENAME).toPath())); // Start from scratch if build changes, since mixing atoms from different builds could // produce strange results. if (!Build.FINGERPRINT.equals(atoms.getBuildFingerprint())) { Log.d(TAG, "[loadAtomsFromFile]: Build changed"); return makeNewPersistMmsAtoms(); } // check all the fields in case of situations such as OTA or crash during saving. List incomingMms = sanitizeAtoms(atoms.getIncomingMmsList(), mMaxNumMms); List outgoingMms = sanitizeAtoms(atoms.getOutgoingMmsList(), mMaxNumMms); long incomingMmsPullTimestamp = sanitizeTimestamp( atoms.getIncomingMmsPullTimestampMillis()); long outgoingMmsPullTimestamp = sanitizeTimestamp( atoms.getOutgoingMmsPullTimestampMillis()); // Rebuild atoms after sanitizing. atoms = atoms.toBuilder() .clearIncomingMms() .clearOutgoingMms() .addAllIncomingMms(incomingMms) .addAllOutgoingMms(outgoingMms) .setIncomingMmsPullTimestampMillis(incomingMmsPullTimestamp) .setOutgoingMmsPullTimestampMillis(outgoingMmsPullTimestamp) .build(); return atoms; } catch (NoSuchFileException e) { Log.e(TAG, "[loadAtomsFromFile]: PersistMmsAtoms file not found"); } catch (IOException | NullPointerException e) { Log.e(TAG, "[loadAtomsFromFile]: cannot load/parse PersistMmsAtoms", e); } return makeNewPersistMmsAtoms(); } /** Adds an IncomingMms to the storage. */ public synchronized void addIncomingMms(IncomingMms mms) { int existingMmsIndex = findIndex(mms); if (existingMmsIndex != -1) { // Update mmsCount and avgIntervalMillis of existingMms. IncomingMms existingMms = mPersistMmsAtoms.getIncomingMms(existingMmsIndex); long updatedMmsCount = existingMms.getMmsCount() + 1; long updatedAvgIntervalMillis = (((existingMms.getAvgIntervalMillis() * existingMms.getMmsCount()) + mms.getAvgIntervalMillis()) / updatedMmsCount); existingMms = existingMms.toBuilder() .setMmsCount(updatedMmsCount) .setAvgIntervalMillis(updatedAvgIntervalMillis) .build(); mPersistMmsAtoms = mPersistMmsAtoms.toBuilder() .setIncomingMms(existingMmsIndex, existingMms) .build(); } else { // Insert new mms at random place. List incomingMmsList = insertAtRandomPlace( mPersistMmsAtoms.getIncomingMmsList(), mms, mMaxNumMms); mPersistMmsAtoms = mPersistMmsAtoms.toBuilder() .clearIncomingMms() .addAllIncomingMms(incomingMmsList) .build(); } saveAtomsToFile(SAVE_TO_FILE_DELAY_FOR_UPDATE_MILLIS); } /** Adds an OutgoingMms to the storage. */ public synchronized void addOutgoingMms(OutgoingMms mms) { int existingMmsIndex = findIndex(mms); if (existingMmsIndex != -1) { // Update mmsCount and avgIntervalMillis of existingMms. OutgoingMms existingMms = mPersistMmsAtoms.getOutgoingMms(existingMmsIndex); long updatedMmsCount = existingMms.getMmsCount() + 1; long updatedAvgIntervalMillis = (((existingMms.getAvgIntervalMillis() * existingMms.getMmsCount()) + mms.getAvgIntervalMillis()) / updatedMmsCount); existingMms = existingMms.toBuilder() .setMmsCount(updatedMmsCount) .setAvgIntervalMillis(updatedAvgIntervalMillis) .build(); mPersistMmsAtoms = mPersistMmsAtoms.toBuilder() .setOutgoingMms(existingMmsIndex, existingMms) .build(); } else { // Insert new mms at random place. List outgoingMmsList = insertAtRandomPlace( mPersistMmsAtoms.getOutgoingMmsList(), mms, mMaxNumMms); mPersistMmsAtoms = mPersistMmsAtoms.toBuilder() .clearOutgoingMms() .addAllOutgoingMms(outgoingMmsList) .build(); } saveAtomsToFile(SAVE_TO_FILE_DELAY_FOR_UPDATE_MILLIS); } /** * Returns and clears the IncomingMms if last pulled longer than {@code minIntervalMillis} ago, * otherwise returns {@code null}. */ @Nullable public synchronized List getIncomingMms(long minIntervalMillis) { if ((getWallTimeMillis() - mPersistMmsAtoms.getIncomingMmsPullTimestampMillis()) > minIntervalMillis) { List previousIncomingMmsList = mPersistMmsAtoms.getIncomingMmsList(); mPersistMmsAtoms = mPersistMmsAtoms.toBuilder() .setIncomingMmsPullTimestampMillis(getWallTimeMillis()) .clearIncomingMms() .build(); saveAtomsToFile(SAVE_TO_FILE_DELAY_FOR_GET_MILLIS); return previousIncomingMmsList; } else { return null; } } /** * Returns and clears the OutgoingMms if last pulled longer than {@code minIntervalMillis} ago, * otherwise returns {@code null}. */ @Nullable public synchronized List getOutgoingMms(long minIntervalMillis) { if ((getWallTimeMillis() - mPersistMmsAtoms.getOutgoingMmsPullTimestampMillis()) > minIntervalMillis) { List previousOutgoingMmsList = mPersistMmsAtoms.getOutgoingMmsList(); mPersistMmsAtoms = mPersistMmsAtoms.toBuilder() .setOutgoingMmsPullTimestampMillis(getWallTimeMillis()) .clearOutgoingMms() .build(); saveAtomsToFile(SAVE_TO_FILE_DELAY_FOR_GET_MILLIS); return previousOutgoingMmsList; } else { return null; } } /** Saves a pending {@link PersistMmsAtoms} to a file in private storage immediately. */ public void flushAtoms() { if (mHandler.hasCallbacks(mSaveRunnable)) { mHandler.removeCallbacks(mSaveRunnable); saveAtomsToFileNow(); } } /** Returns an empty PersistMmsAtoms with pull timestamp set to current time. */ private PersistMmsAtoms makeNewPersistMmsAtoms() { // allow pulling only after some time so data are sufficiently aggregated. long currentTime = getWallTimeMillis(); PersistMmsAtoms atoms = PersistMmsAtoms.newBuilder() .setBuildFingerprint(Build.FINGERPRINT) .setIncomingMmsPullTimestampMillis(currentTime) .setOutgoingMmsPullTimestampMillis(currentTime) .build(); return atoms; } /** * Posts message to save a copy of {@link PersistMmsAtoms} to a file after a delay. * *

The delay is introduced to avoid too frequent operations to disk, which would negatively * impact the power consumption. */ private void saveAtomsToFile(int delayMillis) { if (delayMillis > 0 && !mSaveImmediately) { mHandler.removeCallbacks(mSaveRunnable); if (mHandler.postDelayed(mSaveRunnable, delayMillis)) { return; } } // In case of error posting the event or if delay is 0, save immediately. saveAtomsToFileNow(); } /** Saves a copy of {@link PersistMmsAtoms} to a file in private storage. */ private synchronized void saveAtomsToFileNow() { try (FileOutputStream stream = mContext.openFileOutput(FILENAME, Context.MODE_PRIVATE)) { stream.write(mPersistMmsAtoms.toByteArray()); } catch (IOException e) { Log.e(TAG, "[saveAtomsToFileNow]: Cannot save PersistMmsAtoms", e); } } /** * Inserts a new element in a random position. */ private static List insertAtRandomPlace(List storage, T instance, int maxSize) { final int storage_size = storage.size(); List result = new ArrayList<>(storage); if (storage_size == 0) { result.add(instance); } else if (storage_size == maxSize) { // Index of the item suitable for eviction is chosen randomly when the array is full. int insertAt = sRandom.nextInt(maxSize); result.set(insertAt, instance); } else { // Insert at random place (by moving the item at the random place to the end). int insertAt = sRandom.nextInt(storage_size); result.add(result.get(insertAt)); result.set(insertAt, instance); } return result; } /** * Returns IncomingMms atom index that has the same dimension values with the given one, * or {@code -1} if it does not exist. */ private int findIndex(IncomingMms key) { for (int i = 0; i < mPersistMmsAtoms.getIncomingMmsCount(); i++) { IncomingMms mms = mPersistMmsAtoms.getIncomingMms(i); if (mms.getRat() == key.getRat() && mms.getResult() == key.getResult() && mms.getRoaming() == key.getRoaming() && mms.getSimSlotIndex() == key.getSimSlotIndex() && mms.getIsMultiSim() == key.getIsMultiSim() && mms.getIsEsim() == key.getIsEsim() && mms.getCarrierId() == key.getCarrierId() && mms.getRetryId() == key.getRetryId() && mms.getHandledByCarrierApp() == key.getHandledByCarrierApp()) { return i; } } return -1; } /** * Returns OutgoingMms atom index that has the same dimension values with the given one, * or {@code -1} if it does not exist. */ private int findIndex(OutgoingMms key) { for (int i = 0; i < mPersistMmsAtoms.getOutgoingMmsCount(); i++) { OutgoingMms mms = mPersistMmsAtoms.getOutgoingMms(i); if (mms.getRat() == key.getRat() && mms.getResult() == key.getResult() && mms.getRoaming() == key.getRoaming() && mms.getSimSlotIndex() == key.getSimSlotIndex() && mms.getIsMultiSim() == key.getIsMultiSim() && mms.getIsEsim() == key.getIsEsim() && mms.getCarrierId() == key.getCarrierId() && mms.getIsFromDefaultApp() == key.getIsFromDefaultApp() && mms.getRetryId() == key.getRetryId() && mms.getHandledByCarrierApp() == key.getHandledByCarrierApp()) { return i; } } return -1; } /** Sanitizes the loaded list of atoms to avoid null values. */ private List sanitizeAtoms(List list) { return list == null ? Collections.emptyList() : list; } /** Sanitizes the loaded list of atoms loaded to avoid null values and enforce max length. */ private List sanitizeAtoms(List list, int maxSize) { list = sanitizeAtoms(list); if (list.size() > maxSize) { return list.subList(0, maxSize); } return list; } /** Sanitizes the timestamp of the last pull loaded from persistent storage. */ private long sanitizeTimestamp(long timestamp) { return timestamp <= 0L ? getWallTimeMillis() : timestamp; } @VisibleForTesting protected long getWallTimeMillis() { // Epoch time in UTC, preserved across reboots, but can be adjusted e.g. by the user or NTP. return System.currentTimeMillis(); } }