1 /*
2  * Copyright (C) 2016 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.storagemanager.deletionhelper;
18 
19 import android.Manifest;
20 import android.app.Activity;
21 import android.content.Context;
22 import android.content.Intent;
23 import android.content.pm.PackageManager;
24 import android.os.Bundle;
25 import android.os.storage.StorageManager;
26 import androidx.annotation.VisibleForTesting;
27 import androidx.preference.PreferenceFragment;
28 import androidx.preference.Preference;
29 import androidx.preference.PreferenceScreen;
30 import android.text.format.Formatter;
31 import android.view.LayoutInflater;
32 import android.view.Menu;
33 import android.view.MenuInflater;
34 import android.view.View;
35 import android.view.ViewGroup;
36 import android.widget.Button;
37 import com.android.internal.logging.MetricsLogger;
38 import com.android.internal.logging.nano.MetricsProto.MetricsEvent;
39 import com.android.internal.util.Preconditions;
40 import com.android.settingslib.HelpUtils;
41 import com.android.settingslib.applications.AppUtils;
42 import com.android.storagemanager.ButtonBarProvider;
43 import com.android.storagemanager.R;
44 import com.android.storagemanager.overlay.DeletionHelperFeatureProvider;
45 import com.android.storagemanager.overlay.FeatureFactory;
46 import java.util.ArrayList;
47 import java.util.HashSet;
48 import java.util.List;
49 
50 /**
51  * Settings screen for the deletion helper, which manually removes data which is not recently used.
52  */
53 public class DeletionHelperSettings extends PreferenceFragment
54         implements DeletionType.FreeableChangedListener, View.OnClickListener {
55     public static final boolean COUNT_UNCHECKED = true;
56     public static final boolean COUNT_CHECKED_ONLY = false;
57 
58     protected static final String APPS_KEY = "apps_group";
59     protected static final String KEY_DOWNLOADS_PREFERENCE = "delete_downloads";
60     protected static final String KEY_PHOTOS_VIDEOS_PREFERENCE = "delete_photos";
61     protected static final String KEY_GAUGE_PREFERENCE = "deletion_gauge";
62 
63     private static final String THRESHOLD_KEY = "threshold_key";
64     private static final int DOWNLOADS_LOADER_ID = 1;
65     private static final int NUM_DELETION_TYPES = 3;
66     private static final long UNSET = -1;
67 
68     private List<DeletionType> mDeletableContentList;
69     private AppDeletionPreferenceGroup mApps;
70     @VisibleForTesting AppDeletionType mAppBackend;
71     @VisibleForTesting DownloadsDeletionPreferenceGroup mDownloadsPreference;
72     private DownloadsDeletionType mDownloadsDeletion;
73     private PhotosDeletionPreference mPhotoPreference;
74     private Preference mGaugePreference;
75     private DeletionType mPhotoVideoDeletion;
76     private Button mCancel, mFree;
77     private DeletionHelperFeatureProvider mProvider;
78     private int mThresholdType;
79     @VisibleForTesting long mBytesToFree = UNSET;
80     private int mResult;
81     private LoadingSpinnerController mLoadingController;
82 
newInstance(int thresholdType)83     public static DeletionHelperSettings newInstance(int thresholdType) {
84         DeletionHelperSettings instance = new DeletionHelperSettings();
85         Bundle bundle = new Bundle(1);
86         bundle.putInt(THRESHOLD_KEY, thresholdType);
87         instance.setArguments(bundle);
88         return instance;
89     }
90 
91     @Override
onCreatePreferences(Bundle savedInstanceState, String rootKey)92     public void onCreatePreferences(Bundle savedInstanceState, String rootKey) {
93         addPreferencesFromResource(R.xml.deletion_helper_list);
94         mThresholdType = getArguments().getInt(THRESHOLD_KEY, AppsAsyncLoader.NORMAL_THRESHOLD);
95         mApps = (AppDeletionPreferenceGroup) findPreference(APPS_KEY);
96         mPhotoPreference = (PhotosDeletionPreference) findPreference(KEY_PHOTOS_VIDEOS_PREFERENCE);
97         mProvider = FeatureFactory.getFactory(getActivity()).getDeletionHelperFeatureProvider();
98         mLoadingController = new LoadingSpinnerController((DeletionHelperActivity) getActivity());
99         if (mProvider != null) {
100             mPhotoVideoDeletion =
101                     mProvider.createPhotoVideoDeletionType(getContext(), mThresholdType);
102         }
103 
104         HashSet<String> checkedApplications = null;
105         if (savedInstanceState != null) {
106             checkedApplications =
107                     (HashSet<String>) savedInstanceState.getSerializable(
108                             AppDeletionType.EXTRA_CHECKED_SET);
109         }
110         mAppBackend = new AppDeletionType(this, checkedApplications, mThresholdType);
111         mAppBackend.registerView(mApps);
112         mAppBackend.registerFreeableChangedListener(this);
113         mApps.setDeletionType(mAppBackend);
114 
115         mDeletableContentList = new ArrayList<>(NUM_DELETION_TYPES);
116 
117         mGaugePreference = findPreference(KEY_GAUGE_PREFERENCE);
118         Activity activity = getActivity();
119         if (activity != null && mGaugePreference != null) {
120             Intent intent = activity.getIntent();
121             if (intent != null) {
122                 CharSequence gaugeTitle =
123                         getGaugeString(getContext(), intent, activity.getCallingPackage());
124                 if (gaugeTitle != null) {
125                     mGaugePreference.setTitle(gaugeTitle);
126 
127                     long requestedBytes =
128                             intent.getLongExtra(StorageManager.EXTRA_REQUESTED_BYTES, UNSET);
129                     mBytesToFree = requestedBytes;
130                 } else {
131                     getPreferenceScreen().removePreference(mGaugePreference);
132                 }
133             }
134         }
135     }
136 
getGaugeString( Context context, Intent intent, String packageName)137     protected static CharSequence getGaugeString(
138             Context context, Intent intent, String packageName) {
139         Preconditions.checkNotNull(intent);
140         long requestedBytes = intent.getLongExtra(StorageManager.EXTRA_REQUESTED_BYTES, UNSET);
141         if (requestedBytes > 0) {
142             CharSequence callerLabel =
143                     AppUtils.getApplicationLabel(context.getPackageManager(), packageName);
144             // I really hope this isn't the case, but I can't ignore the possibility that we cannot
145             // determine what app the referrer is.
146             if (callerLabel == null) {
147                 return null;
148             }
149             return context.getString(
150                     R.string.app_requesting_space,
151                     callerLabel,
152                     Formatter.formatFileSize(context, requestedBytes));
153         }
154         return null;
155     }
156 
157     @Override
onActivityCreated(Bundle savedInstanceState)158     public void onActivityCreated(Bundle savedInstanceState) {
159         super.onActivityCreated(savedInstanceState);
160         initializeButtons();
161         setHasOptionsMenu(true);
162         Activity activity = getActivity();
163         if (activity.checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE)
164                 != PackageManager.PERMISSION_GRANTED) {
165             activity.requestPermissions(
166                     new String[] {Manifest.permission.READ_EXTERNAL_STORAGE},
167                     0);
168         }
169 
170         if (mProvider != null && mPhotoVideoDeletion != null) {
171             mPhotoPreference.setDaysToKeep(mProvider.getDaysToKeep(mThresholdType));
172             mPhotoPreference.registerFreeableChangedListener(this);
173             mPhotoPreference.registerDeletionService(mPhotoVideoDeletion);
174             mDeletableContentList.add(mPhotoVideoDeletion);
175         } else {
176             getPreferenceScreen().removePreference(mPhotoPreference);
177             mPhotoPreference.setEnabled(false);
178         }
179 
180         String[] uncheckedFiles = null;
181         if (savedInstanceState != null) {
182             uncheckedFiles =
183                     savedInstanceState.getStringArray(
184                             DownloadsDeletionType.EXTRA_UNCHECKED_DOWNLOADS);
185         }
186         mDownloadsPreference =
187                 (DownloadsDeletionPreferenceGroup) findPreference(KEY_DOWNLOADS_PREFERENCE);
188         mDownloadsDeletion = new DownloadsDeletionType(getActivity(), uncheckedFiles);
189         mDownloadsPreference.registerFreeableChangedListener(this);
190         mDownloadsPreference.registerDeletionService(mDownloadsDeletion);
191         mDeletableContentList.add(mDownloadsDeletion);
192         if (isEmptyState()) {
193             setupEmptyState();
194         }
195         mDeletableContentList.add(mAppBackend);
196         updateFreeButtonText();
197     }
198 
199     @VisibleForTesting
setupEmptyState()200     void setupEmptyState() {
201         final PreferenceScreen screen = getPreferenceScreen();
202         if (mDownloadsPreference != null) {
203             mDownloadsPreference.setChecked(false);
204             screen.removePreference(mDownloadsPreference);
205         }
206         screen.removePreference(mApps);
207 
208         // Nulling out the downloads preferences means we won't accidentally delete what isn't
209         // visible.
210         mDownloadsDeletion = null;
211         mDownloadsPreference = null;
212     }
213 
isEmptyState()214     private boolean isEmptyState() {
215         // We know we are in the empty state if our loader is not using a threshold.
216         return mThresholdType == AppsAsyncLoader.NO_THRESHOLD;
217     }
218 
219     @Override
onResume()220     public void onResume() {
221         super.onResume();
222 
223         mLoadingController.initializeLoading(getListView());
224 
225         for (int i = 0, size = mDeletableContentList.size(); i < size; i++) {
226             mDeletableContentList.get(i).onResume();
227         }
228 
229         if (mDownloadsDeletion != null
230                 && getActivity().checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE)
231                         == PackageManager.PERMISSION_GRANTED) {
232             getLoaderManager().initLoader(DOWNLOADS_LOADER_ID, new Bundle(), mDownloadsDeletion);
233         }
234     }
235 
236     @Override
onPause()237     public void onPause() {
238         super.onPause();
239         for (int i = 0, size = mDeletableContentList.size(); i < size; i++) {
240             mDeletableContentList.get(i).onPause();
241         }
242     }
243 
244     @Override
onSaveInstanceState(Bundle outState)245     public void onSaveInstanceState(Bundle outState) {
246         super.onSaveInstanceState(outState);
247         for (int i = 0, size = mDeletableContentList.size(); i < size; i++) {
248             mDeletableContentList.get(i).onSaveInstanceStateBundle(outState);
249         }
250     }
251 
252     @Override
onFreeableChanged(int numItems, long bytesFreeable)253     public void onFreeableChanged(int numItems, long bytesFreeable) {
254         if (numItems > 0 || bytesFreeable > 0 || allTypesEmpty()) {
255             if (mLoadingController != null) {
256                 mLoadingController.onCategoryLoad();
257             }
258         }
259 
260         // bytesFreeable is the number of bytes freed by a single deletion type. If it is non-zero,
261         // there is stuff to free and we can enable it. If it is zero, though, we still need to get
262         // getTotalFreeableSpace to check all deletion types.
263         if (mFree != null) {
264             mFree.setEnabled(bytesFreeable != 0 || getTotalFreeableSpace(COUNT_CHECKED_ONLY) != 0);
265         }
266         updateFreeButtonText();
267 
268         // Transition to empty state if all types have reported there is nothing to delete. Skip
269         // the transition if we are already in no threshold mode
270         if (allTypesEmpty() && !isEmptyState()) {
271             startEmptyState();
272         }
273     }
274 
allTypesEmpty()275     private boolean allTypesEmpty() {
276         return mAppBackend.isEmpty()
277                 && (mDownloadsDeletion == null || mDownloadsDeletion.isEmpty())
278                 && (mPhotoVideoDeletion == null || mPhotoVideoDeletion.isEmpty());
279     }
280 
startEmptyState()281     private void startEmptyState() {
282         if (getActivity() instanceof DeletionHelperActivity) {
283             DeletionHelperActivity activity = (DeletionHelperActivity) getActivity();
284             activity.setIsEmptyState(true /* isEmptyState */);
285         }
286     }
287 
288     /** Clears out the selected apps and data from the device and closes the fragment. */
clearData()289     protected void clearData() {
290         long bytesFreed = getTotalFreeableSpace(COUNT_CHECKED_ONLY);
291         if (mBytesToFree != UNSET && bytesFreed >= mBytesToFree) {
292             setResultCode(Activity.RESULT_OK);
293         }
294 
295         // This should be fine as long as there is only one extra deletion feature.
296         // In the future, this should be done in an async queue in order to not
297         // interfere with the simultaneous PackageDeletionTask.
298         Activity activity = getActivity();
299         if (mPhotoPreference != null && mPhotoPreference.isChecked()) {
300             mPhotoVideoDeletion.clearFreeableData(activity);
301         }
302         if (mDownloadsPreference != null) {
303             mDownloadsDeletion.clearFreeableData(activity);
304         }
305         if (mAppBackend != null) {
306             mAppBackend.clearFreeableData(activity);
307         }
308     }
309 
310     @Override
onClick(View v)311     public void onClick(View v) {
312         if (v.getId() == R.id.next_button) {
313             ConfirmDeletionDialog dialog =
314                     ConfirmDeletionDialog.newInstance(getTotalFreeableSpace(COUNT_CHECKED_ONLY));
315             // The 0 is a placeholder for an optional result code.
316             dialog.setTargetFragment(this, 0);
317             dialog.show(getFragmentManager(), ConfirmDeletionDialog.TAG);
318             MetricsLogger.action(getContext(), MetricsEvent.ACTION_DELETION_HELPER_CLEAR);
319         } else {
320             MetricsLogger.action(getContext(), MetricsEvent.ACTION_DELETION_HELPER_CANCEL);
321             getActivity().finish();
322         }
323     }
324 
325     @Override
onRequestPermissionsResult(int requestCode, String permissions[], int[] grantResults)326     public void onRequestPermissionsResult(int requestCode, String permissions[],
327                                            int[] grantResults) {
328         if (requestCode == 0) {
329             if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
330                 mDownloadsDeletion.onResume();
331                 getLoaderManager().initLoader(DOWNLOADS_LOADER_ID, new Bundle(),
332                         mDownloadsDeletion);
333             }
334         }
335     }
336 
337     @Override
onCreateOptionsMenu(Menu menu, MenuInflater menuInflater)338     public void onCreateOptionsMenu(Menu menu, MenuInflater menuInflater) {
339         Activity activity = getActivity();
340         String mHelpUri = getResources().getString(R.string.help_uri_deletion_helper);
341         if (mHelpUri != null && activity != null) {
342             HelpUtils.prepareHelpMenuItem(activity, menu, mHelpUri, getClass().getName());
343         }
344     }
345 
346     @Override
onCreateView( LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState)347     public View onCreateView(
348             LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
349         View view = super.onCreateView(inflater, container, savedInstanceState);
350         return view;
351     }
352 
353     @VisibleForTesting
setDownloadsDeletionType(DownloadsDeletionType downloadsDeletion)354     void setDownloadsDeletionType(DownloadsDeletionType downloadsDeletion) {
355         mDownloadsDeletion = downloadsDeletion;
356     }
357 
initializeButtons()358     private void initializeButtons() {
359         ButtonBarProvider activity = (ButtonBarProvider) getActivity();
360         activity.getButtonBar().setVisibility(View.VISIBLE);
361 
362         mCancel = activity.getSkipButton();
363         mCancel.setText(R.string.cancel);
364         mCancel.setOnClickListener(this);
365         mCancel.setVisibility(View.VISIBLE);
366 
367         mFree = activity.getNextButton();
368         mFree.setText(R.string.storage_menu_free);
369         mFree.setOnClickListener(this);
370         mFree.setEnabled(false);
371     }
372 
updateFreeButtonText()373     private void updateFreeButtonText() {
374         Activity activity = getActivity();
375         if (activity == null) {
376             return;
377         }
378         mFree.setText(
379                 String.format(
380                         activity.getString(R.string.deletion_helper_free_button),
381                         Formatter.formatFileSize(
382                                 activity, getTotalFreeableSpace(COUNT_CHECKED_ONLY))));
383     }
384 
getTotalFreeableSpace(boolean countUnchecked)385     private long getTotalFreeableSpace(boolean countUnchecked) {
386         long freeableSpace = 0;
387         if (mAppBackend != null) {
388             freeableSpace += mAppBackend.getTotalAppsFreeableSpace(countUnchecked);
389         }
390         if (mPhotoPreference != null) {
391             freeableSpace += mPhotoPreference.getFreeableBytes(countUnchecked);
392         }
393         if (mDownloadsPreference != null) {
394             freeableSpace += mDownloadsDeletion.getFreeableBytes(countUnchecked);
395         }
396         return freeableSpace;
397     }
398 
setResultCode(int result)399     private void setResultCode(int result) {
400         mResult = result;
401         Activity activity = getActivity();
402         if (activity != null) {
403             activity.setResult(result);
404         }
405     }
406 
407     @VisibleForTesting
getResultCode()408     protected int getResultCode() {
409         return mResult;
410     }
411 }
412