/* * 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.devicelockcontroller.activities; import static com.android.devicelockcontroller.activities.ProvisioningActivity.EXTRA_SHOW_PROVISION_FAILED_UI_ON_START; import static com.google.common.base.Preconditions.checkNotNull; import android.annotation.SuppressLint; import android.app.Notification; import android.app.NotificationChannel; import android.app.NotificationManager; import android.app.PendingIntent; import android.content.Context; import android.content.Intent; import android.os.SystemClock; import android.widget.RemoteViews; import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationManagerCompat; import com.android.devicelockcontroller.DeviceLockControllerApplication; import com.android.devicelockcontroller.R; import com.android.devicelockcontroller.storage.SetupParametersClient; import com.android.devicelockcontroller.storage.UserParameters; import com.android.devicelockcontroller.util.LogUtil; import com.android.devicelockcontroller.util.StringUtil; import com.android.internal.annotations.GuardedBy; import com.android.internal.annotations.VisibleForTesting; import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListeningExecutorService; import com.google.common.util.concurrent.MoreExecutors; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.time.format.FormatStyle; import java.util.UUID; import java.util.concurrent.Executors; /** * A singleton class used to send notifications. */ public final class DeviceLockNotificationManager { private static final String TAG = "DeviceLockNotificationManager"; private static final String PROVISION_NOTIFICATION_CHANNEL_ID_BASE = "devicelock-provision"; @VisibleForTesting public static final String DEVICE_RESET_NOTIFICATION_TAG = "devicelock-device-reset"; @VisibleForTesting public static final int DEVICE_RESET_NOTIFICATION_ID = 0; private static final int DEFER_PROVISIONING_NOTIFICATION_ID = 1; @GuardedBy("DeviceLockNotificationManager.class") private static DeviceLockNotificationManager sInstance; private final ListeningExecutorService mListeningExecutorService; /** * Get instance of {@link DeviceLockNotificationManager}. */ public static DeviceLockNotificationManager getInstance() { synchronized (DeviceLockNotificationManager.class) { if (sInstance == null) { createAndSetDeviceLockNotificationManager( MoreExecutors.listeningDecorator(Executors.newCachedThreadPool())); } return sInstance; } } /** * Create and set instance of {@link DeviceLockNotificationManager}. */ @VisibleForTesting public static void createAndSetDeviceLockNotificationManager( ListeningExecutorService executor) { synchronized (DeviceLockNotificationManager.class) { sInstance = new DeviceLockNotificationManager(executor); } } private DeviceLockNotificationManager(ListeningExecutorService listeningExecutorService) { mListeningExecutorService = listeningExecutorService; } /** * Similar to {@link #sendDeviceResetNotification(Context, int)}, except that: * 1. The number of days to reset is always one. * 2. The notification is ongoing. */ public void sendDeviceResetInOneDayOngoingNotification(Context context) { sendDeviceResetNotification(context, /* days= */ 1, /* ongoing= */ true); } /** * Send the device reset notification. The call is thread safe and can be called from any * thread. * * @param context the context where the notification will be sent out * @param days the number of days the reset will happen */ public void sendDeviceResetNotification(Context context, int days) { sendDeviceResetNotification(context, days, /* ongoing= */ false); } /** * Send the device reset timer notification. * * @param context the context where the notification will be sent out * @param countDownBase the time when device will be reset in * {@link SystemClock#elapsedRealtime()}. */ public void sendDeviceResetTimerNotification(Context context, long countDownBase) { ListenableFuture channelIdFuture = createNotificationChannel(context); ListenableFuture kioskAppProviderNameFuture = SetupParametersClient.getInstance().getKioskAppProviderName(); ListenableFuture result = Futures.whenAllSucceed(channelIdFuture, kioskAppProviderNameFuture).call(() -> { String channelId = Futures.getDone(channelIdFuture); String providerName = Futures.getDone(kioskAppProviderNameFuture); Notification notification = new NotificationCompat.Builder(context, channelId) .setSmallIcon(R.drawable.ic_action_lock) .setColor(context.getResources() .getColor(R.color.notification_background_color, context.getTheme())) .setOngoing(true) .setStyle(new NotificationCompat.DecoratedCustomViewStyle()) .setCustomContentView( buildResetTimerNotif(countDownBase, providerName, false)) .setCustomBigContentView( buildResetTimerNotif(countDownBase, providerName, true)) .build(); NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); notificationManager.notify(DEVICE_RESET_NOTIFICATION_TAG, DEVICE_RESET_NOTIFICATION_ID, notification); return null; }, mListeningExecutorService); Futures.addCallback(result, new FutureCallback<>() { @Override public void onSuccess(Void result) { LogUtil.d(TAG, "send device reset notification"); } @Override public void onFailure(Throwable t) { LogUtil.e(TAG, "Failed to create device reset notification", t); } }, mListeningExecutorService); } private static RemoteViews buildResetTimerNotif(long countDownBase, String providerName, boolean isExpanded) { Context appContext = DeviceLockControllerApplication.getAppContext(); RemoteViews content = new RemoteViews(appContext.getPackageName(), isExpanded ? R.layout.reset_timer_notif_large : R.layout.reset_timer_notif_small); content.setChronometer(R.id.reset_timer_title, countDownBase, appContext.getString(R.string.device_reset_timer_notification_title), /* started= */ true); if (isExpanded) { content.setCharSequence(R.id.reset_content, "setText", appContext.getString(R.string.device_reset_notification_content, providerName)); } return content; } private void sendDeviceResetNotification(Context context, int days, boolean ongoing) { // TODO: check/request permission first // re-creating the same notification channel is essentially no-op ListenableFuture channelIdFuture = createNotificationChannel(context); ListenableFuture notificationFuture = Futures.transformAsync(channelIdFuture, channelId -> createDeviceResetNotification(context, days, ongoing, channelId), mListeningExecutorService); Futures.addCallback(notificationFuture, new FutureCallback<>() { @Override public void onSuccess(Notification notification) { LogUtil.d(TAG, "send device reset notification"); NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); notificationManager.notify(DEVICE_RESET_NOTIFICATION_TAG, DEVICE_RESET_NOTIFICATION_ID, notification); } @Override public void onFailure(Throwable t) { LogUtil.e(TAG, "Failed to create device reset notification", t); } }, mListeningExecutorService); } /** * Send the device deferred provisioning notification. * (POST_NOTIFICATION already requested permission in ProvisionInfoFragment). * * @param context the context where the notification will be sent out * @param resumeDateTime the time when device will show the notification * @Param pendingIntent pending intent for the notification */ @SuppressLint("MissingPermission") public void sendDeferredProvisioningNotification(Context context, LocalDateTime resumeDateTime, PendingIntent pendingIntent) { ListenableFuture channelIdFuture = createNotificationChannel(context); Futures.addCallback(channelIdFuture, new FutureCallback() { @Override public void onSuccess(String channelId) { DateTimeFormatter timeFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT); String enrollmentResumeTime = timeFormatter.format(resumeDateTime); NotificationCompat.Builder notificationBuilder = new NotificationCompat.Builder( context, channelId) .setContentTitle(context.getString(R.string.device_enrollment_header_text)) .setContentText(context .getString(R.string.device_enrollment_notification_body_text, enrollmentResumeTime)) .setSmallIcon(R.drawable.ic_action_lock) .setColor(context.getResources() .getColor(R.color.notification_background_color, context.getTheme())) .setContentIntent(pendingIntent) .setAutoCancel(true) .setOngoing(true); NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); notificationManager.notify(DEFER_PROVISIONING_NOTIFICATION_ID, notificationBuilder.build()); } @Override public void onFailure(Throwable t) { LogUtil.e(TAG, "Failed to create deferred provisioning notification", t); } }, mListeningExecutorService); } void cancelDeferredProvisioningNotification(Context context) { LogUtil.d(TAG, "cancelDeferredEnrollmentNotification"); NotificationManagerCompat.from(context).cancel(DEFER_PROVISIONING_NOTIFICATION_ID); } private ListenableFuture createDeviceResetNotification(Context context, int days, boolean ongoing, String channelId) { return Futures.transform(SetupParametersClient.getInstance().getKioskAppProviderName(), providerName -> new NotificationCompat.Builder(context, channelId) .setSmallIcon(R.drawable.ic_action_lock) .setColor(context.getResources() .getColor(R.color.notification_background_color, context.getTheme())) .setOngoing(ongoing) .setContentTitle(StringUtil.getPluralString(context, days, R.string.device_reset_in_days_notification_title)) .setContentText(context.getString( R.string.device_reset_notification_content, providerName)) .setContentIntent( PendingIntent.getActivity(context, /* requestCode= */0, new Intent(context, ProvisioningActivity.class).putExtra( EXTRA_SHOW_PROVISION_FAILED_UI_ON_START, true), PendingIntent.FLAG_IMMUTABLE)).build(), context.getMainExecutor()); } private static String getProvisionNotificationChannelId(Context context) { return PROVISION_NOTIFICATION_CHANNEL_ID_BASE + "-" + UserParameters.getNotificationChannelIdSuffix(context); } private static String createProvisionNotificationChannelId(Context context) { String notificationChannelIdSuffix = UUID.randomUUID().toString(); String provisioningChannelId = PROVISION_NOTIFICATION_CHANNEL_ID_BASE + "-" + notificationChannelIdSuffix; UserParameters.setNotificationChannelIdSuffix(context, notificationChannelIdSuffix); return provisioningChannelId; } // Create a notification channel and return a future with its ID. private ListenableFuture createNotificationChannel(Context context) { return mListeningExecutorService.submit(() -> { String provisioningChannelId = getProvisionNotificationChannelId(context); NotificationManager notificationManager = context.getSystemService(NotificationManager.class); checkNotNull(notificationManager); NotificationChannel notificationChannel = notificationManager.getNotificationChannel(provisioningChannelId); if (notificationChannel != null && notificationChannel.getImportance() != NotificationManager.IMPORTANCE_HIGH) { // Channel importance has been changed by user LogUtil.w(TAG, "Channel " + provisioningChannelId + " importance changed by user, deleting it"); notificationManager.deleteNotificationChannel(provisioningChannelId); notificationChannel = null; } if (notificationChannel == null) { provisioningChannelId = createProvisionNotificationChannelId(context); notificationChannel = new NotificationChannel( provisioningChannelId, context.getString(R.string.provision_notification_channel_name), NotificationManager.IMPORTANCE_HIGH); LogUtil.i(TAG, "New notification channel: " + provisioningChannelId); } notificationManager.createNotificationChannel(notificationChannel); return provisioningChannelId; }); } }