blob: 421d0e407bcb6d6141ae97b18385df6f60852dc4 [file]
/*
* Copyright (C) 2025 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.server.telecom;
import android.content.BroadcastReceiver;
import android.content.ContentProvider;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.net.Uri;
import android.os.UserHandle;
import android.os.UserManager;
import android.provider.CallLog;
import android.telecom.Log;
import android.telecom.TelecomManager;
import android.text.TextUtils;
import androidx.annotation.VisibleForTesting;
import com.android.server.telecom.flags.FeatureFlags;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Collectors;
/**
* Adapter to handle VoIP call log integration.
*/
public class CallLogIntegrationAdapterImpl implements CallLogIntegrationAdapter {
public static final String SHARED_PREFERENCES_NAME = "voip_call_log_integration_prefs";
private static final String TAG = CallLogIntegrationAdapterImpl.class.getSimpleName();
private static final String SHARED_PREFERENCES_KEY = "voip_call_log_integration_key";
// TODO(b/469253692): This constant is hidden in IntentFilter.
// Redefined locally to remove hidden API dependency.
private static final String SCHEME_PACKAGE = "package";
private static final Intent CALLBACK_INTENT = new Intent(TelecomManager.ACTION_CALL_BACK);
// We may need to force load from the shared preferences, i.e. when performing restore from
// backup. The local cache may need to be invalidated in this case.
private static final AtomicBoolean sForceSharedPrefLoading = new AtomicBoolean(true);
private final Context mContext;
// Store the enabled state for each supported VoIP package per user. This is used keeping track
// of updates in the shared preferences for each user.
private final Map<UserHandle, Map<String, Boolean>> mEnabledPackageStates = new HashMap<>();
// All the packages supported for VoIP call log integration as reported by querying the
// broadcast receiver.
private final Map<UserHandle, Set<String>> mSupportedPackages = new HashMap<>();
// The following maps are used to keep track of package updates (whether a new package was
// added or removed) for apps registering the ACTION_CALL_BACK intent. We use this caching
// mechanism to lazily update the SharedPreferences for each user only when Settings queries
// for the list via the overridden getter method.
private final Map<UserHandle, Set<String>> mPackagesToAdd = new HashMap<>();
private final Map<UserHandle, Set<String>> mPackagesToRemove = new HashMap<>();
// Needed to synchronize access to the above maps. Note that using a concurrent DS like
// ConcurrentHashMap does not guarantee the atomicity of the operations being performed in
// getSupportedVoipCallLogIntegrationPackages and setVoipPackageCallLogIntegrationEnabled. We
// also don't need to use a concurrent DS on top of a lock as it adds unnecessary overhead and
// adds confusion as to why both practices are being implemented.
private final Object mLock = new Object();
private final Executor mExecutor = Executors.newSingleThreadExecutor();
private final FeatureFlags mFeatureFlags;
/**
* Receiver to detect when packages are added or removed. It is filtered to only packages that
* have defined the ACTION_CALL_BACK intent.
*/
private final BroadcastReceiver mPackageChangedReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
String action = intent.getAction();
if (action == null || intent.getData() == null) {
return;
}
String packageName = intent.getData().getSchemeSpecificPart();
if (TextUtils.isEmpty(packageName)) {
return;
}
final int uid = intent.getIntExtra(Intent.EXTRA_UID, -1);
if (uid == -1) {
return;
}
final UserHandle userHandle = UserHandle.getUserHandleForUid(uid);
// Check that the package received supports the callback intent before adding it to
// corresponding maps to signal that an update is needed. We may also need to see if the
// package is stored in our internal cache in the case that the application was
// uninstalled.
boolean containsCachedPackage = mEnabledPackageStates.containsKey(userHandle)
&& mEnabledPackageStates.get(userHandle).containsKey(packageName);
if (doesPackageSupportCallback(packageName, userHandle) || containsCachedPackage) {
synchronized (mLock) {
Log.i(TAG, "VoIP package %s changed for user %s with intent %s", packageName,
userHandle, intent.getAction());
if (Intent.ACTION_PACKAGE_ADDED.equals(intent.getAction())) {
mPackagesToAdd.putIfAbsent(userHandle, new HashSet<>());
mPackagesToAdd.get(userHandle).add(packageName);
} else if (Intent.ACTION_PACKAGE_REMOVED.equals(intent.getAction())) {
mPackagesToRemove.putIfAbsent(userHandle, new HashSet<>());
mPackagesToRemove.get(userHandle).add(packageName);
removeLogEntries(mContext, packageName, userHandle, mExecutor);
}
}
} else {
Log.d(TAG, "Ignoring package change for %s as it is not a relevant VoIP app.",
packageName);
}
}
};
/**
* Checks if a given package has registered a broadcast receiver for
* TelecomManager.ACTION_CALL_BACK for a specific user.
*
* @param packageName The package to check.
* @param userHandle The user for which to check.
* @return {@code true} if the package is relevant, {@code false} otherwise.
*/
private boolean doesPackageSupportCallback(String packageName, UserHandle userHandle) {
Context userContext = createUserContext(mContext, userHandle);
if (userContext == null) {
return false;
}
PackageManager packageManager = userContext.getPackageManager();
Intent checkIntent = new Intent(TelecomManager.ACTION_CALL_BACK);
checkIntent.setPackage(packageName);
// Check if the package supports the callback
List<ResolveInfo> resolveInfoList = packageManager.queryIntentActivities(checkIntent,
PackageManager.MATCH_ALL);
return !resolveInfoList.isEmpty();
}
/**
* Register the package changed receiver to detect when packages are added or removed for all
* users.
*/
private void registerPackageChangeReceiver() {
IntentFilter packageChangedFilter = new IntentFilter();
packageChangedFilter.addAction(Intent.ACTION_PACKAGE_ADDED);
packageChangedFilter.addAction(Intent.ACTION_PACKAGE_REMOVED);
packageChangedFilter.addDataScheme(SCHEME_PACKAGE);
Context allUsersContext = createUserContext(mContext, UserHandle.ALL);
if (allUsersContext != null) {
allUsersContext.registerReceiver(mPackageChangedReceiver, packageChangedFilter,
null, null);
}
}
public CallLogIntegrationAdapterImpl(Context context, FeatureFlags featureFlags) {
mContext = context;
mFeatureFlags = featureFlags;
registerPackageChangeReceiver();
initCallLogPreferencesForRunningUsers();
}
public void initCallLogPreferencesForRunningUsers() {
// Populate the initial cache with all the running users
UserManager userManager = mContext.getSystemService(UserManager.class);
List<UserHandle> runningUsers = userManager.getUserHandles(true /* excludeDying */);
for (UserHandle user: runningUsers) {
// This will create the internal map from the SharedPreferences for the user and also
// notify apps, upon init, for disabled prefs.
getSupportedVoipCallLogIntegrationPackages(user);
}
}
/**
* Sets the enabled state for VoIP call log integration for a specific app and user. An enabled
* app is allowed to integrate its calls into the system call log.
*
* @param userHandle The user for whom the setting is being changed.
* @param packageName The package name to update.
* @param isEnabled The new enabled state.
*/
@Override
public void setVoipPackageCallLogIntegrationEnabled(UserHandle userHandle, String packageName,
boolean isEnabled) {
Context userContext = createUserContext(mContext, userHandle);
if (userContext == null || TextUtils.isEmpty(packageName)
|| !android.telecom.flags.Flags.integratedCallLogsStage2()) {
return;
}
synchronized (mLock) {
// This operates on the fact that if the user is toggling the enabled state from
// settings (as they should), then getSupportedVoipPackages() will always run first and
// populate the pkg enabled states into the map for the given user. We should never
// receive an invocation to the setter before the getter is invoked.
if (!mEnabledPackageStates.containsKey(userHandle)) {
return;
}
Map<String, Boolean> enabledPackageStates = mEnabledPackageStates.get(userHandle);
if (!enabledPackageStates.containsKey(packageName)
|| enabledPackageStates.get(packageName) == isEnabled) {
Log.w(TAG, "Package %s is not available for user %s", packageName, userHandle);
return;
}
// Update the map with the new enabled state.
mEnabledPackageStates.get(userHandle).put(packageName, isEnabled);
// Update the corresponding SharedPreferences for the user.
updateSharedPrefForUser(userContext, mEnabledPackageStates.get(userHandle));
// Ensure that we remove log entries if app integration is disabled by the user.
if (!isEnabled) {
removeLogEntries(mContext, packageName, userHandle, mExecutor);
}
// Notify the app of the new preference change.
notifyAppOfPreferenceChange(mContext, userHandle, packageName, isEnabled);
}
}
/**
* Retrieves a map of the app's package names that have registered a broadcast receiver
* for the TelecomManager.ACTION_CALL_BACK intent to the enabled state of if user has allowed
* the app to integrate its call logs into the system call log for a specific user.
*
* @param userHandle The user for which to query the packages and their enabled states.
* @return A map containing the app package names to their enabled states.
*/
@Override
public Map<String, Boolean> getSupportedVoipCallLogIntegrationPackages(UserHandle userHandle) {
if (!android.telecom.flags.Flags.integratedCallLogsStage2()) {
return new HashMap<>();
}
Log.i(TAG, "getSupportedVoipCallLogIntegrationPackages: user %s",
userHandle.getIdentifier());
Context userContext = createUserContext(mContext, userHandle);
if (userContext == null) {
// Remove the user mapping if it exists.
mEnabledPackageStates.remove(userHandle);
return new HashMap<>();
}
synchronized (mLock) {
// If we detect that the shared preferences mapping hasn't been defined yet or we
// require a signal from the broadcast receiver that a package has been updated, then
// update the mapping first.
// Todo: Replace with `!mEnabledPackageStates.containsKey(userHandle)
// || doSupportedPackagesNeedUpdate(userHandle)` once apps have released versions of
// call log integration support. For testing purposes, this becomes difficult to verify
// since we may need to overlay test apks with released versions. In the case of Meet,
// we cannot uninstall the app so when we initialize the prefs upon reboot, the pref
// will always be disabled and will not be changed if we don't force an update.
boolean shouldUpdate = true;
if (shouldUpdate) {
boolean preferenceStateNotDefined = !mEnabledPackageStates.containsKey(userHandle);
// Get all the packages for the user that have registered the ACTION_CALL_BACK
// intent. This is queried from the broadcast receivers.
Set<String> allSupportedPackages = getSupportedPackages(userContext, userHandle,
shouldUpdate);
// Try to load the pkg enabled states set for the user from the shared preferences
// if it doesn't already exist in the map.
loadSharedPrefForUser(userContext, userHandle);
Map<String, Boolean> enabledPkgStatesForUser = mEnabledPackageStates.get(
userHandle);
// Update the existing shared preferences from the packages retrieved from the
// broadcast query.
pruneSharedPreferences(userContext, enabledPkgStatesForUser, allSupportedPackages);
// If this is the first time loading from SharedPreferences, notify all apps what
// whose preference is disabled by the user. By default, the preference is enabled
// so we can notify the app only if it got disabled.
if (preferenceStateNotDefined) {
// Filter by disabled package prefs.
Map<String, Boolean> disabledPkgPrefs = enabledPkgStatesForUser
.entrySet().stream().filter(entry -> !entry.getValue())
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
notifyAppsOfPreferenceChangeForUser(mContext, userHandle, mExecutor,
disabledPkgPrefs);
}
}
// Return a the map of package names to their enabled states. Return a copy to prevent
// modification outside of the lock.
return new HashMap<>(mEnabledPackageStates.get(userHandle));
}
}
/**
* Returns whether the given package is enabled (by the user) for integrating its logs into the
* system call log.
* @return {@code true} if the package can integrate its call logs, {@code false} otherwise.
*/
@Override
public boolean isCallLogPrefEnabledForPackage(UserHandle userHandle, String packageName) {
Map<String, Boolean> supportedPackagesForUser =
getSupportedVoipCallLogIntegrationPackages(userHandle);
return supportedPackagesForUser.containsKey(packageName)
&& supportedPackagesForUser.get(packageName);
}
/**
* Prunes the shared preferences by cross-checking against the broadcast query for the packages.
* Updates the existing shared preferences if there was a change.
*/
private static void pruneSharedPreferences(Context userContext,
Map<String, Boolean> enabledPkgStatesForUser, Set<String> allSupportedPackages) {
boolean isUpdated = false;
// Prune the map and remove any old references (apps may have been uninstalled).
isUpdated |= enabledPkgStatesForUser.entrySet().removeIf(
entry -> !allSupportedPackages.contains(entry.getKey()));
// Go through each supported package and add it into the map if it's not already
// present in the set.
for (String pkgName : allSupportedPackages) {
// Add the entry if it doesn't exist and set the default enabled state to true.
// Note here that PackageEnabledState only considers the package name when
// comparing for equality.
if (enabledPkgStatesForUser.putIfAbsent(pkgName, true) == null) {
isUpdated = true;
}
}
// Persist the data only if there was an update.
if (isUpdated) {
updateSharedPrefForUser(userContext, enabledPkgStatesForUser);
}
}
private Set<String> getSupportedPackages(Context userContext,
UserHandle userHandle, boolean shouldUpdate) {
boolean performedQuery = false;
Log.i(TAG, "Getting supported VoIP packages for user %s", userHandle);
if (!mSupportedPackages.containsKey(userHandle) || shouldUpdate) {
mSupportedPackages.put(userHandle, querySupportedPackages(userContext));
performedQuery = true;
}
Set<String> supportedPackages = mSupportedPackages.get(userHandle);
// If we already performed a manual query, no need to look at packages added/removed since
// those should already be accounted for when querying the broadcast receivers.
if (!performedQuery) {
if (mPackagesToAdd.containsKey(userHandle)) {
supportedPackages.addAll(mPackagesToAdd.get(userHandle));
}
if (mPackagesToRemove.containsKey(userHandle)) {
supportedPackages.removeAll(mPackagesToRemove.get(userHandle));
}
}
// Clear the maps to signal that we finished processing the updates.
mPackagesToAdd.remove(userHandle);
mPackagesToRemove.remove(userHandle);
return supportedPackages;
}
/**
* Manually query the supported packages for the user from the pkg manager.
*/
private static Set<String> querySupportedPackages(Context userContext) {
Log.i(TAG, "Querying supported VoIP packages");
PackageManager packageManager = userContext.getPackageManager();
List<ResolveInfo> resolveInfoList = packageManager
.queryIntentActivities(CALLBACK_INTENT, PackageManager.MATCH_ALL);
// Extract the package name from each ResolveInfo and return as a set.
return resolveInfoList.stream()
.map(resolveInfo -> {
if (resolveInfo.activityInfo != null) {
return resolveInfo.activityInfo.packageName;
}
return null;
})
.filter(Objects::nonNull)
.collect(Collectors.toSet());
}
/**
* Loads the map from the shared preferences if the key exists for the user. Otherwise, a new
* {key, value} pair is inserted.
*/
private void loadSharedPrefForUser(Context userContext, UserHandle userHandle) {
// Skip if the map already contains a valid set for the user and a force update is not
// required.
if (mEnabledPackageStates.containsKey(userHandle)
&& !sForceSharedPrefLoading.compareAndSet(true, false)) {
return;
}
Map<String, Boolean> pkgEnabledStatesMap = getSharedPrefsForUser(userContext, userHandle);
mEnabledPackageStates.put(userHandle, pkgEnabledStatesMap);
}
/**
* Static helper to retrieve the SharedPreferences for the user.
*/
public static Map<String, Boolean> getSharedPrefsForUser(Context userContext,
UserHandle userHandle ) {
Log.i(TAG, "Loading shared preferences for user %s", userHandle);
// Get the shared preference for the user if it exists. Otherwise, we can skip this process.
SharedPreferences prefs = userContext.getSharedPreferences(
SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE);
String pkgEnabledStatesFromPref = prefs.getString(SHARED_PREFERENCES_KEY, "");
if (TextUtils.isEmpty(pkgEnabledStatesFromPref)) {
return new HashMap<>();
}
// Deserialize the string into the resulting set which can be added to the map.
return Arrays.stream(pkgEnabledStatesFromPref.split(","))
.map(s -> s.split(":"))
.filter(entry -> entry.length == 2 && !TextUtils.isEmpty(entry[0]))
.collect(Collectors.toMap(
entry -> entry[0],
entry -> Boolean.parseBoolean(entry[1])));
}
/**
* Update the SharedPreferences for the specified user by deserializing the map reference.
* @param userContext The user context for which to update the SharedPreferences.
* @param packageEnabledStates The VoIP package enabled states to store in SharedPreferences.
*/
private static void updateSharedPrefForUser(Context userContext,
Map<String, Boolean> packageEnabledStates) {
Log.i(TAG, "Updating shared preferences.");
// Get the SharedPreferences file for userHandle
SharedPreferences prefs = userContext.getSharedPreferences(
SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE);
// Serialize the map entries and apply the update asynchronously
String updatedPackagesEnabledStates = packageEnabledStates
.entrySet().stream()
.map(entry -> entry.getKey() + ":" + entry.getValue())
.collect(Collectors.joining(","));
SharedPreferences.Editor editor = prefs.edit();
editor.putString(SHARED_PREFERENCES_KEY, updatedPackagesEnabledStates);
editor.apply();
}
/**
* Remove the existing system call log entries for the specified package and user.
* @param packageName The package name for the VoIP application.
* @param userHandle The {@link UserHandle} that we should remove the entries for .
*/
private static void removeLogEntries(Context context, String packageName, UserHandle userHandle,
Executor executor) {
if (!android.telecom.flags.Flags.integratedCallLogsStage2()) {
return;
}
Context userContext = createUserContext(context, userHandle);
if (userContext == null) {
userContext = context;
}
final String selection = CallLog.Calls.PHONE_ACCOUNT_COMPONENT_NAME + " LIKE '"
+ packageName + "%'";
Uri appendedUserUri = ContentProvider.createContentUriForUser(
CallLog.Calls.CONTENT_URI_WITH_VOIP_CALLS, userHandle);
Context finalUserContext = userContext;
executor.execute(() -> {
try {
int rowsDeleted = finalUserContext.getContentResolver().delete(appendedUserUri,
selection, null);
Log.d(TAG, "Deleted %d VoIP call log entries for %s", rowsDeleted, packageName);
} catch (Exception e) {
Log.e(TAG, e, "Error clearing VoIP call log entries for %s", packageName);
}
});
}
private boolean doSupportedPackagesNeedUpdate(UserHandle userHandle) {
return mPackagesToAdd.containsKey(userHandle) || mPackagesToRemove.containsKey(userHandle);
}
private static void notifyAppsOfPreferenceChangeForUser(Context context, UserHandle userHandle,
Executor executor, Map<String, Boolean> preferences) {
if (!android.telecom.flags.Flags.integratedCallLogsStage2()) {
return;
}
for (Map.Entry<String, Boolean> entry : preferences.entrySet()) {
String packageName = entry.getKey();
Boolean enabledState = entry.getValue();
notifyAppOfPreferenceChange(context, userHandle, packageName, enabledState);
// Ensure entries are deleted if we load in the shared prefs for the first
// time and the preference is disabled.
if (!enabledState) {
removeLogEntries(context, packageName, userHandle, executor);
}
}
}
private static void notifyAppOfPreferenceChange(Context context, UserHandle userHandle,
String pkgName, boolean enabled) {
if (!android.telecom.flags.Flags.integratedCallLogsStage2()) {
return;
}
Context userContext = createUserContext(context, userHandle);
if (userContext == null) {
userContext = context;
}
Log.d(TAG, "Notifying package %s of preference change(%b) for user %s", pkgName,
enabled, userHandle);
Intent intent = new Intent(TelecomManager.ACTION_VOIP_CALL_LOG_PREFERENCE);
intent.setPackage(pkgName);
intent.putExtra(TelecomManager.EXTRA_VOIP_CALL_LOG_PREFERENCE_STATUS, enabled);
userContext.sendBroadcast(intent);
}
public static void handleNotifyAppsOfPreferenceOnRestore(Context context, UserHandle userHandle,
Executor executor) {
Context userContext = createUserContext(context, userHandle);
if (userContext == null) {
userContext = context;
}
Map<String, Boolean> sharedPrefsForUser = getSharedPrefsForUser(userContext, userHandle);
// Next time the local cache is checked, make sure we invalidate it so that it's properly
// updated with the potentially new shared preferences that were restored from the backup.
if (!sharedPrefsForUser.isEmpty()) {
sForceSharedPrefLoading.set(true);
}
Set<String> supportedPackages = querySupportedPackages(userContext);
// This will modify the existing sharedPrefsForUser map.
pruneSharedPreferences(userContext, sharedPrefsForUser, supportedPackages);
// Now notify all apps for the preferences. We cannot ignore packages that have enabled
// prefs since the device restoring may have different data from the backup.
notifyAppsOfPreferenceChangeForUser(userContext, userHandle, executor, sharedPrefsForUser);
}
private static Context createUserContext(Context context, UserHandle userhandle) {
try {
return context.createContextAsUser(userhandle, 0);
} catch (IllegalStateException e) {
Log.e(TAG, e, "Error while creating context as user = %s", userhandle);
return null;
}
}
@VisibleForTesting
public Map<UserHandle, Set<String>> getPackagesToAdd() {
return mPackagesToAdd;
}
@VisibleForTesting
public Map<UserHandle, Set<String>> getPackagesToRemove() {
return mPackagesToRemove;
}
@VisibleForTesting
public BroadcastReceiver getPackageChangedReceiver() {
return mPackageChangedReceiver;
}
}