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.tv.common.recording;
18 
19 import android.content.BroadcastReceiver;
20 import android.content.ContentResolver;
21 import android.content.Context;
22 import android.content.Intent;
23 import android.content.IntentFilter;
24 import android.os.Environment;
25 import android.os.Looper;
26 import android.os.StatFs;
27 import android.support.annotation.AnyThread;
28 import android.support.annotation.IntDef;
29 import android.support.annotation.WorkerThread;
30 import android.util.Log;
31 
32 import com.android.tv.common.SoftPreconditions;
33 import com.android.tv.common.dagger.annotations.ApplicationContext;
34 import com.android.tv.common.feature.CommonFeatures;
35 
36 import java.io.File;
37 import java.io.IOException;
38 import java.lang.annotation.Retention;
39 import java.lang.annotation.RetentionPolicy;
40 import java.util.Objects;
41 import java.util.Set;
42 import java.util.concurrent.CopyOnWriteArraySet;
43 
44 import javax.inject.Inject;
45 import javax.inject.Singleton;
46 
47 /** Signals DVR storage status change such as plugging/unplugging. */
48 @Singleton
49 public class RecordingStorageStatusManager {
50     private static final String TAG = "RecordingStorageStatusManager";
51 
52     /** Minimum storage size to support DVR */
53     public static final long MIN_STORAGE_SIZE_FOR_DVR_IN_BYTES = 50 * 1024 * 1024 * 1024L; // 50GB
54 
55     private static final long MIN_FREE_STORAGE_SIZE_FOR_DVR_IN_BYTES =
56             10 * 1024 * 1024 * 1024L; // 10GB
57     private static final String RECORDING_DATA_SUB_PATH = "/recording";
58 
59     /** Storage status constants. */
60     @IntDef({
61         STORAGE_STATUS_OK,
62         STORAGE_STATUS_TOTAL_CAPACITY_TOO_SMALL,
63         STORAGE_STATUS_FREE_SPACE_INSUFFICIENT,
64         STORAGE_STATUS_MISSING
65     })
66     @Retention(RetentionPolicy.SOURCE)
67     public @interface StorageStatus {}
68 
69     /** Current storage is OK to record a program. */
70     public static final int STORAGE_STATUS_OK = 0;
71 
72     /** Current storage's total capacity is smaller than DVR requirement. */
73     public static final int STORAGE_STATUS_TOTAL_CAPACITY_TOO_SMALL = 1;
74 
75     /** Current storage's free space is insufficient to record programs. */
76     public static final int STORAGE_STATUS_FREE_SPACE_INSUFFICIENT = 2;
77 
78     /** Current storage is missing. */
79     public static final int STORAGE_STATUS_MISSING = 3;
80 
81     private final Context mContext;
82     private final Set<OnStorageMountChangedListener> mOnStorageMountChangedListeners =
83             new CopyOnWriteArraySet<>();
84     private MountedStorageStatus mMountedStorageStatus;
85     private boolean mStorageValid;
86 
87     private class MountedStorageStatus {
88         private final boolean mStorageMounted;
89         private final File mStorageMountedDir;
90         private final long mStorageMountedCapacity;
91 
MountedStorageStatus(boolean mounted, File mountedDir, long capacity)92         private MountedStorageStatus(boolean mounted, File mountedDir, long capacity) {
93             mStorageMounted = mounted;
94             mStorageMountedDir = mountedDir;
95             mStorageMountedCapacity = capacity;
96         }
97 
isValidForDvr()98         private boolean isValidForDvr() {
99             return mStorageMounted && mStorageMountedCapacity >= MIN_STORAGE_SIZE_FOR_DVR_IN_BYTES;
100         }
101 
102         @Override
equals(Object other)103         public boolean equals(Object other) {
104             if (!(other instanceof MountedStorageStatus)) {
105                 return false;
106             }
107             MountedStorageStatus status = (MountedStorageStatus) other;
108             return mStorageMounted == status.mStorageMounted
109                     && Objects.equals(mStorageMountedDir, status.mStorageMountedDir)
110                     && mStorageMountedCapacity == status.mStorageMountedCapacity;
111         }
112     }
113 
114     public interface OnStorageMountChangedListener {
115 
116         /**
117          * Listener for DVR storage status change.
118          *
119          * @param storageMounted {@code true} when DVR possible storage is mounted, {@code false}
120          *     otherwise.
121          */
onStorageMountChanged(boolean storageMounted)122         void onStorageMountChanged(boolean storageMounted);
123     }
124 
125     private final class StorageStatusBroadcastReceiver extends BroadcastReceiver {
126         @Override
onReceive(Context context, Intent intent)127         public void onReceive(Context context, Intent intent) {
128             MountedStorageStatus result = getStorageStatusInternal();
129             if (mMountedStorageStatus.equals(result)) {
130                 return;
131             }
132             mMountedStorageStatus = result;
133             if (result.mStorageMounted) {
134                 cleanUpDbIfNeeded();
135             }
136             boolean valid = result.isValidForDvr();
137             if (valid == mStorageValid) {
138                 return;
139             }
140             mStorageValid = valid;
141             for (OnStorageMountChangedListener l : mOnStorageMountChangedListeners) {
142                 l.onStorageMountChanged(valid);
143             }
144         }
145     }
146 
147     /**
148      * Creates RecordingStorageStatusManager.
149      *
150      * @param context {@link Context}
151      */
152     @Inject
RecordingStorageStatusManager(@pplicationContext Context context)153     public RecordingStorageStatusManager(@ApplicationContext Context context) {
154         mContext = context;
155         mMountedStorageStatus = getStorageStatusInternal();
156         mStorageValid = mMountedStorageStatus.isValidForDvr();
157         IntentFilter filter = new IntentFilter();
158         filter.addAction(Intent.ACTION_MEDIA_MOUNTED);
159         filter.addAction(Intent.ACTION_MEDIA_UNMOUNTED);
160         filter.addAction(Intent.ACTION_MEDIA_EJECT);
161         filter.addAction(Intent.ACTION_MEDIA_REMOVED);
162         filter.addAction(Intent.ACTION_MEDIA_BAD_REMOVAL);
163         filter.addDataScheme(ContentResolver.SCHEME_FILE);
164         mContext.registerReceiver(new StorageStatusBroadcastReceiver(), filter);
165     }
166 
167     /**
168      * Adds the listener for receiving storage status change.
169      *
170      * @param listener
171      */
addListener(OnStorageMountChangedListener listener)172     public void addListener(OnStorageMountChangedListener listener) {
173         mOnStorageMountChangedListeners.add(listener);
174     }
175 
176     /** Removes the current listener. */
removeListener(OnStorageMountChangedListener listener)177     public void removeListener(OnStorageMountChangedListener listener) {
178         mOnStorageMountChangedListeners.remove(listener);
179     }
180 
181     /** Returns true if a storage is mounted. */
isStorageMounted()182     public boolean isStorageMounted() {
183         return mMountedStorageStatus.mStorageMounted;
184     }
185 
186     /** Returns the path to DVR recording data directory. This can take for a while sometimes. */
187     @WorkerThread
getRecordingRootDataDirectory()188     public File getRecordingRootDataDirectory() {
189         SoftPreconditions.checkState(Looper.myLooper() != Looper.getMainLooper());
190         if (mMountedStorageStatus.mStorageMountedDir == null) {
191             return null;
192         }
193         File root = mContext.getExternalFilesDir(null);
194         String rootPath;
195         try {
196             rootPath = root != null ? root.getCanonicalPath() : null;
197         } catch (IOException | SecurityException e) {
198             return null;
199         }
200         return rootPath == null ? null : new File(rootPath + RECORDING_DATA_SUB_PATH);
201     }
202 
203     /**
204      * Returns the current storage status for DVR recordings.
205      *
206      * @return {@link StorageStatus}
207      */
208     @AnyThread
getDvrStorageStatus()209     public @StorageStatus int getDvrStorageStatus() {
210         MountedStorageStatus status = mMountedStorageStatus;
211         if (status.mStorageMountedDir == null) {
212             return STORAGE_STATUS_MISSING;
213         }
214         if (CommonFeatures.FORCE_RECORDING_UNTIL_NO_SPACE.isEnabled(mContext)) {
215             return STORAGE_STATUS_OK;
216         }
217         if (status.mStorageMountedCapacity < MIN_STORAGE_SIZE_FOR_DVR_IN_BYTES) {
218             return STORAGE_STATUS_TOTAL_CAPACITY_TOO_SMALL;
219         }
220         try {
221             StatFs statFs = new StatFs(status.mStorageMountedDir.toString());
222             if (statFs.getAvailableBytes() < MIN_FREE_STORAGE_SIZE_FOR_DVR_IN_BYTES) {
223                 return STORAGE_STATUS_FREE_SPACE_INSUFFICIENT;
224             }
225         } catch (IllegalArgumentException e) {
226             // In rare cases, storage status change was not notified yet.
227             Log.w(TAG, "Error getting Dvr Storage Status.", e);
228             SoftPreconditions.checkState(false);
229             return STORAGE_STATUS_FREE_SPACE_INSUFFICIENT;
230         }
231         return STORAGE_STATUS_OK;
232     }
233 
234     /**
235      * Returns whether the storage has sufficient storage.
236      *
237      * @return {@code true} when there is sufficient storage, {@code false} otherwise
238      */
isStorageSufficient()239     public boolean isStorageSufficient() {
240         return getDvrStorageStatus() == STORAGE_STATUS_OK;
241     }
242 
243     /** APPs that want to clean up DB for recordings should override this method to do the job. */
cleanUpDbIfNeeded()244     protected void cleanUpDbIfNeeded() {}
245 
getStorageStatusInternal()246     private MountedStorageStatus getStorageStatusInternal() {
247         boolean storageMounted =
248                 Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED);
249         File storageMountedDir = storageMounted ? Environment.getExternalStorageDirectory() : null;
250         storageMounted = storageMounted && storageMountedDir != null;
251         long storageMountedCapacity = 0L;
252         if (storageMounted) {
253             try {
254                 StatFs statFs = new StatFs(storageMountedDir.toString());
255                 storageMountedCapacity = statFs.getTotalBytes();
256             } catch (IllegalArgumentException e) {
257                 Log.w(TAG, "Storage mount status was changed.", e);
258                 storageMounted = false;
259                 storageMountedDir = null;
260             }
261         }
262         return new MountedStorageStatus(storageMounted, storageMountedDir, storageMountedCapacity);
263     }
264 }
265