blob: bbbe7e631d7d1ccc26ae5c54a425443c5ce6293e [file] [log] [blame]
/*
* 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.adservices.service.consent;
import static com.android.adservices.service.stats.AdServicesStatsLog.AD_SERVICES_SETTINGS_USAGE_REPORTED;
import static com.android.adservices.service.stats.AdServicesStatsLog.AD_SERVICES_SETTINGS_USAGE_REPORTED__ACTION__OPT_IN_SELECTED;
import static com.android.adservices.service.stats.AdServicesStatsLog.AD_SERVICES_SETTINGS_USAGE_REPORTED__ACTION__OPT_OUT_SELECTED;
import static com.android.adservices.service.stats.AdServicesStatsLog.AD_SERVICES_SETTINGS_USAGE_REPORTED__REGION__EU;
import static com.android.adservices.service.stats.AdServicesStatsLog.AD_SERVICES_SETTINGS_USAGE_REPORTED__REGION__ROW;
import android.annotation.NonNull;
import android.app.job.JobScheduler;
import android.content.Context;
import com.android.adservices.LogUtil;
import com.android.adservices.data.common.BooleanFileDatastore;
import com.android.adservices.data.consent.AppConsentDao;
import com.android.adservices.data.customaudience.CustomAudienceDao;
import com.android.adservices.data.customaudience.CustomAudienceDatabase;
import com.android.adservices.data.enrollment.EnrollmentDao;
import com.android.adservices.data.topics.Topic;
import com.android.adservices.data.topics.TopicsTables;
import com.android.adservices.service.Flags;
import com.android.adservices.service.FlagsFactory;
import com.android.adservices.service.common.BackgroundJobsManager;
import com.android.adservices.service.measurement.MeasurementImpl;
import com.android.adservices.service.stats.AdServicesLoggerImpl;
import com.android.adservices.service.stats.UIStats;
import com.android.adservices.service.topics.TopicsWorker;
import com.google.common.collect.ImmutableList;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.stream.Collectors;
/**
* Manager to handle user's consent.
*
* <p>For Beta the consent is given for all {@link AdServicesApiType} or for none.
*/
public class ConsentManager {
private static final String ERROR_MESSAGE_DATASTORE_EXCEPTION_WHILE_GET_CONTENT =
"getConsent method failed. Revoked consent is returned as fallback.";
private static final String NOTIFICATION_DISPLAYED_ONCE = "NOTIFICATION-DISPLAYED-ONCE";
private static final String CONSENT_ALREADY_INITIALIZED_KEY = "CONSENT-ALREADY-INITIALIZED";
private static final String CONSENT_KEY = "CONSENT";
private static final String CONSENT_PER_API_FORMAT = "CONSENT-FOR-%s-API";
private static final String ERROR_MESSAGE_DATASTORE_IO_EXCEPTION_WHILE_SET_CONTENT =
"setConsent method failed due to IOException thrown by Datastore.";
private static final int STORAGE_VERSION = 1;
private static final String STORAGE_XML_IDENTIFIER = "ConsentManagerStorageIdentifier.xml";
private static volatile ConsentManager sConsentManager;
private final Flags mFlags;
private volatile Boolean mInitialized = false;
private final TopicsWorker mTopicsWorker;
private final BooleanFileDatastore mDatastore;
private final AppConsentDao mAppConsentDao;
private final EnrollmentDao mEnrollmentDao;
private final MeasurementImpl mMeasurementImpl;
private final AdServicesLoggerImpl mAdServicesLoggerImpl;
private final int mDeviceLoggingRegion;
private final CustomAudienceDao mCustomAudienceDao;
private final ExecutorService mExecutor;
ConsentManager(
@NonNull Context context,
@NonNull TopicsWorker topicsWorker,
@NonNull AppConsentDao appConsentDao,
@NonNull EnrollmentDao enrollmentDao,
@NonNull MeasurementImpl measurementImpl,
@NonNull AdServicesLoggerImpl adServicesLoggerImpl,
@NonNull CustomAudienceDao customAudienceDao,
Flags flags) {
Objects.requireNonNull(context);
Objects.requireNonNull(topicsWorker);
Objects.requireNonNull(appConsentDao);
Objects.requireNonNull(measurementImpl);
Objects.requireNonNull(adServicesLoggerImpl);
Objects.requireNonNull(customAudienceDao);
mTopicsWorker = topicsWorker;
mDatastore = new BooleanFileDatastore(context, STORAGE_XML_IDENTIFIER, STORAGE_VERSION);
mAppConsentDao = appConsentDao;
mEnrollmentDao = enrollmentDao;
mMeasurementImpl = measurementImpl;
mAdServicesLoggerImpl = adServicesLoggerImpl;
mCustomAudienceDao = customAudienceDao;
mExecutor = Executors.newSingleThreadExecutor();
mFlags = flags;
mDeviceLoggingRegion = initializeLoggingValues(context);
}
/**
* Gets an instance of {@link ConsentManager} to be used.
*
* <p>If no instance has been initialized yet, a new one will be created. Otherwise, the
* existing instance will be returned.
*/
@NonNull
public static ConsentManager getInstance(@NonNull Context context) {
Objects.requireNonNull(context);
if (sConsentManager == null) {
synchronized (ConsentManager.class) {
if (sConsentManager == null) {
sConsentManager =
new ConsentManager(
context,
TopicsWorker.getInstance(context),
AppConsentDao.getInstance(context),
EnrollmentDao.getInstance(context),
MeasurementImpl.getInstance(context),
AdServicesLoggerImpl.getInstance(),
CustomAudienceDatabase.getInstance(context).customAudienceDao(),
FlagsFactory.getFlags());
}
}
}
return sConsentManager;
}
/**
* Enables all PP API services. It gives consent to Topics, Fledge and Measurements services.
*/
public void enable(@NonNull Context context) {
Objects.requireNonNull(context);
mAdServicesLoggerImpl.logUIStats(
new UIStats.Builder()
.setCode(AD_SERVICES_SETTINGS_USAGE_REPORTED)
.setRegion(mDeviceLoggingRegion)
.setAction(AD_SERVICES_SETTINGS_USAGE_REPORTED__ACTION__OPT_IN_SELECTED)
.build());
// Enable all the APIs
try {
init();
BackgroundJobsManager.scheduleAllBackgroundJobs(context);
setConsent(AdServicesApiConsent.GIVEN);
} catch (IOException e) {
LogUtil.e(e, ERROR_MESSAGE_DATASTORE_IO_EXCEPTION_WHILE_SET_CONTENT);
throw new RuntimeException(ERROR_MESSAGE_DATASTORE_IO_EXCEPTION_WHILE_SET_CONTENT, e);
}
}
/**
* Disables all PP API services. It revokes consent to Topics, Fledge and Measurements services.
*/
public void disable(@NonNull Context context) {
Objects.requireNonNull(context);
mAdServicesLoggerImpl.logUIStats(
new UIStats.Builder()
.setCode(AD_SERVICES_SETTINGS_USAGE_REPORTED)
.setRegion(mDeviceLoggingRegion)
.setAction(AD_SERVICES_SETTINGS_USAGE_REPORTED__ACTION__OPT_OUT_SELECTED)
.build());
// Disable all the APIs
try {
init();
// reset all data
resetTopicsAndBlockedTopics();
resetAppsAndBlockedApps();
resetMeasurement();
resetEnrollment();
BackgroundJobsManager.unscheduleAllBackgroundJobs(
context.getSystemService(JobScheduler.class));
setConsent(AdServicesApiConsent.REVOKED);
} catch (IOException e) {
LogUtil.e(e, ERROR_MESSAGE_DATASTORE_IO_EXCEPTION_WHILE_SET_CONTENT);
throw new RuntimeException(ERROR_MESSAGE_DATASTORE_IO_EXCEPTION_WHILE_SET_CONTENT, e);
}
}
/** Retrieves the consent for all PP API services. */
public AdServicesApiConsent getConsent() {
if (mFlags.getConsentManagerDebugMode()) {
return AdServicesApiConsent.GIVEN;
}
try {
init();
return AdServicesApiConsent.getConsent(mDatastore.get(CONSENT_KEY));
} catch (NullPointerException | IllegalArgumentException | IOException e) {
LogUtil.e(e, ERROR_MESSAGE_DATASTORE_EXCEPTION_WHILE_GET_CONTENT);
return AdServicesApiConsent.REVOKED;
}
}
/**
* Retrieves the consent per API.
*
* @param apiType apiType for which the consent should be provided
* @return {@link AdServicesApiConsent} providing information whether the consent was given or
* revoked.
*/
public AdServicesApiConsent getConsent(AdServicesApiType apiType) {
if (mFlags.getConsentManagerDebugMode()) {
return AdServicesApiConsent.GIVEN;
}
try {
init(apiType);
return AdServicesApiConsent.getConsent(mDatastore.get(getConsentKeyPerApi(apiType)));
} catch (NullPointerException | IllegalArgumentException | IOException e) {
LogUtil.e(e, ERROR_MESSAGE_DATASTORE_EXCEPTION_WHILE_GET_CONTENT);
return AdServicesApiConsent.REVOKED;
}
}
/**
* Proxy call to {@link TopicsWorker} to get {@link ImmutableList} of {@link Topic}s which could
* be returned to the {@link TopicsWorker} clients.
*
* @return {@link ImmutableList} of {@link Topic}s.
*/
@NonNull
public ImmutableList<Topic> getKnownTopicsWithConsent() {
return mTopicsWorker.getKnownTopicsWithConsent();
}
/**
* Proxy call to {@link TopicsWorker} to get {@link ImmutableList} of {@link Topic}s which were
* blocked by the user.
*
* @return {@link ImmutableList} of blocked {@link Topic}s.
*/
@NonNull
public ImmutableList<Topic> getTopicsWithRevokedConsent() {
return mTopicsWorker.getTopicsWithRevokedConsent();
}
/**
* Proxy call to {@link TopicsWorker} to revoke consent for provided {@link Topic} (block
* topic).
*
* @param topic {@link Topic} to block.
*/
@NonNull
public void revokeConsentForTopic(@NonNull Topic topic) {
mTopicsWorker.revokeConsentForTopic(topic);
}
/**
* Proxy call to {@link TopicsWorker} to restore consent for provided {@link Topic} (unblock the
* topic).
*
* @param topic {@link Topic} to restore consent for.
*/
@NonNull
public void restoreConsentForTopic(@NonNull Topic topic) {
mTopicsWorker.restoreConsentForTopic(topic);
}
/** Wipes out all the data gathered by Topics API but blocked topics. */
public void resetTopics() {
ArrayList<String> tablesToBlock = new ArrayList<>();
tablesToBlock.add(TopicsTables.BlockedTopicsContract.TABLE);
mTopicsWorker.clearAllTopicsData(tablesToBlock);
}
/** Wipes out all the data gathered by Topics API. */
public void resetTopicsAndBlockedTopics() {
mTopicsWorker.clearAllTopicsData(new ArrayList<>());
}
/**
* @return an {@link ImmutableList} of all known apps in the database that have not had user
* consent revoked
*/
public ImmutableList<App> getKnownAppsWithConsent() {
try {
return ImmutableList.copyOf(
mAppConsentDao.getKnownAppsWithConsent().stream()
.map(App::create)
.collect(Collectors.toList()));
} catch (IOException e) {
LogUtil.e(e, "getKnownAppsWithConsent failed due to IOException.");
return ImmutableList.of();
}
}
/**
* @return an {@link ImmutableList} of all known apps in the database that have had user consent
* revoked
*/
public ImmutableList<App> getAppsWithRevokedConsent() {
try {
return ImmutableList.copyOf(
mAppConsentDao.getAppsWithRevokedConsent().stream()
.map(App::create)
.collect(Collectors.toList()));
} catch (IOException e) {
LogUtil.e(e, "getAppsWithRevokedConsent failed due to IOException.");
return ImmutableList.of();
}
}
/**
* Proxy call to {@link AppConsentDao} to revoke consent for provided {@link App}.
*
* <p>Also clears all app data related to the provided {@link App}.
*
* @param app {@link App} to block.
* @throws IOException if the operation fails
*/
public void revokeConsentForApp(@NonNull App app) throws IOException {
mAppConsentDao.setConsentForApp(app.getPackageName(), true);
asyncExecute(
() -> mCustomAudienceDao.deleteCustomAudienceDataByOwner(app.getPackageName()));
}
/**
* Proxy call to {@link AppConsentDao} to restore consent for provided {@link App}.
*
* @param app {@link App} to restore consent for.
* @throws IOException if the operation fails
*/
public void restoreConsentForApp(@NonNull App app) throws IOException {
mAppConsentDao.setConsentForApp(app.getPackageName(), false);
}
/**
* Deletes all app consent data and all app data gathered or generated by the Privacy Sandbox.
*
* <p>This should be called when the Privacy Sandbox has been disabled.
*
* @throws IOException if the operation fails
*/
public void resetAppsAndBlockedApps() throws IOException {
mAppConsentDao.clearAllConsentData();
asyncExecute(mCustomAudienceDao::deleteAllCustomAudienceData);
}
/**
* Deletes the list of known allowed apps as well as all app data from the Privacy Sandbox.
*
* <p>The list of blocked apps is not reset.
*
* @throws IOException if the operation fails
*/
public void resetApps() throws IOException {
mAppConsentDao.clearKnownAppsWithConsent();
asyncExecute(mCustomAudienceDao::deleteAllCustomAudienceData);
}
/**
* Checks whether a single given installed application (identified by its package name) has had
* user consent to use the FLEDGE APIs revoked.
*
* <p>This method also checks whether a user has opted out of the FLEDGE Privacy Sandbox
* initiative.
*
* @param packageName String package name that uniquely identifies an installed application to
* check
* @return {@code true} if either the FLEDGE Privacy Sandbox initiative has been opted out or if
* the user has revoked consent for the given application to use the FLEDGE APIs
* @throws IllegalArgumentException if the package name is invalid or not found as an installed
* application
*/
public boolean isFledgeConsentRevokedForApp(@NonNull String packageName)
throws IllegalArgumentException {
// TODO(b/238464639): Implement API-specific consent for FLEDGE
if (!getConsent().isGiven()) {
return true;
}
try {
return mAppConsentDao.isConsentRevokedForApp(packageName);
} catch (IOException exception) {
LogUtil.e(exception, "FLEDGE consent check failed due to IOException");
return true;
}
}
/**
* Persists the use of a FLEDGE API by a single given installed application (identified by its
* package name) if the app has not already had its consent revoked.
*
* <p>This method also checks whether a user has opted out of the FLEDGE Privacy Sandbox
* initiative.
*
* <p>This is only meant to be called by the FLEDGE APIs.
*
* @param packageName String package name that uniquely identifies an installed application that
* has used a FLEDGE API
* @return {@code true} if user consent has been revoked for the application or API, {@code
* false} otherwise
* @throws IllegalArgumentException if the package name is invalid or not found as an installed
* application
*/
public boolean isFledgeConsentRevokedForAppAfterSettingFledgeUse(@NonNull String packageName)
throws IllegalArgumentException {
// TODO(b/238464639): Implement API-specific consent for FLEDGE
if (!getConsent().isGiven()) {
return true;
}
try {
return mAppConsentDao.setConsentForAppIfNew(packageName, false);
} catch (IOException exception) {
LogUtil.e(exception, "FLEDGE consent check failed due to IOException");
return true;
}
}
/** Wipes out all the data gathered by Measurement API. */
public void resetMeasurement() {
mMeasurementImpl.deleteAllMeasurementData(List.of());
}
/** Wipes out all the Enrollment data */
private void resetEnrollment() {
mEnrollmentDao.deleteAll();
}
/**
* Saves information to the storage that notification was displayed for the first time to the
* user.
*/
public void recordNotificationDisplayed() {
try {
init();
// TODO(b/229725886): add metrics / logging
mDatastore.put(NOTIFICATION_DISPLAYED_ONCE, true);
} catch (IOException e) {
LogUtil.e(e, "Record notification failed due to IOException thrown by Datastore.");
}
}
/**
* Returns information whether Consent Notification was displayed or not.
*
* @return true if Consent Notification was displayed, otherwise false.
*/
public Boolean wasNotificationDisplayed() {
try {
init();
return mDatastore.get(NOTIFICATION_DISPLAYED_ONCE);
} catch (IOException e) {
LogUtil.e(e, "Record notification failed due to IOException thrown by Datastore.");
return false;
}
}
private void setConsent(AdServicesApiConsent state) throws IOException {
mDatastore.put(CONSENT_KEY, state.isGiven());
}
private void setConsent(AdServicesApiConsent state, AdServicesApiType apiType)
throws IOException {
mDatastore.put(getConsentKeyPerApi(apiType), state.isGiven());
}
void init() throws IOException {
initializeStorage();
if (mDatastore.get(CONSENT_ALREADY_INITIALIZED_KEY) == null
|| mDatastore.get(CONSENT_KEY) == null) {
mDatastore.put(NOTIFICATION_DISPLAYED_ONCE, false);
mDatastore.put(CONSENT_ALREADY_INITIALIZED_KEY, true);
}
}
/*
Method to initialize the datastore and the ConsentManager itself, but also prepare the
consent-per-api if upgrade happens. If that's the case, there is a chance that the aggregated
consent was already initialized (set to GIVEN or REVOKED) and the consents-per-api weren't.
Init method detects such situations and update the consents accordingly.
*/
void init(AdServicesApiType apiType) throws IOException {
init();
Boolean aggregatedConsent = mDatastore.get(CONSENT_KEY);
// if consent wasn't initialized at all, noop
if (aggregatedConsent == null) {
return;
}
String consentPerApiKey = getConsentKeyPerApi(apiType);
// if consent per api was already initialized, noop
if (mDatastore.get(consentPerApiKey) != null) {
return;
}
mDatastore.put(consentPerApiKey, aggregatedConsent);
}
private String getConsentKeyPerApi(AdServicesApiType apiType) {
return String.format(CONSENT_PER_API_FORMAT, apiType.name());
}
private void initializeStorage() throws IOException {
if (!mInitialized) {
synchronized (ConsentManager.class) {
if (!mInitialized) {
mDatastore.initialize();
mInitialized = true;
}
}
}
}
private int initializeLoggingValues(Context context) {
if (DeviceRegionProvider.isEuDevice(context)) {
return AD_SERVICES_SETTINGS_USAGE_REPORTED__REGION__EU;
} else {
return AD_SERVICES_SETTINGS_USAGE_REPORTED__REGION__ROW;
}
}
/**
* Represents revoked consent as internally determined by the PP APIs.
*
* <p>This is an internal-only exception and is not meant to be returned to external callers.
*/
public static class RevokedConsentException extends IllegalStateException {
public static final String REVOKED_CONSENT_ERROR_MESSAGE =
"Error caused by revoked user consent";
/** Creates an instance of a {@link RevokedConsentException}. */
public RevokedConsentException() {
super(REVOKED_CONSENT_ERROR_MESSAGE);
}
}
private void asyncExecute(Runnable runnable) {
mExecutor.execute(runnable);
}
}