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.systemui.statusbar.notification.row.wrapper;
18 
19 import static com.android.systemui.Dependency.MAIN_HANDLER;
20 
21 import android.annotation.Nullable;
22 import android.app.Notification;
23 import android.content.Context;
24 import android.content.res.ColorStateList;
25 import android.media.MediaMetadata;
26 import android.media.session.MediaController;
27 import android.media.session.MediaSession;
28 import android.media.session.PlaybackState;
29 import android.metrics.LogMaker;
30 import android.os.Handler;
31 import android.text.format.DateUtils;
32 import android.view.LayoutInflater;
33 import android.view.View;
34 import android.view.ViewStub;
35 import android.widget.SeekBar;
36 import android.widget.TextView;
37 
38 import com.android.internal.R;
39 import com.android.internal.annotations.VisibleForTesting;
40 import com.android.internal.logging.MetricsLogger;
41 import com.android.internal.logging.nano.MetricsProto.MetricsEvent;
42 import com.android.internal.widget.MediaNotificationView;
43 import com.android.systemui.Dependency;
44 import com.android.systemui.statusbar.NotificationMediaManager;
45 import com.android.systemui.statusbar.TransformableView;
46 import com.android.systemui.statusbar.notification.row.ExpandableNotificationRow;
47 
48 import java.util.Timer;
49 import java.util.TimerTask;
50 
51 /**
52  * Wraps a notification containing a media template
53  */
54 public class NotificationMediaTemplateViewWrapper extends NotificationTemplateViewWrapper {
55 
56     private static final long PROGRESS_UPDATE_INTERVAL = 1000; // 1s
57     private static final String COMPACT_MEDIA_TAG = "media";
58     private final Handler mHandler = Dependency.get(MAIN_HANDLER);
59     private Timer mSeekBarTimer;
60     private View mActions;
61     private SeekBar mSeekBar;
62     private TextView mSeekBarElapsedTime;
63     private TextView mSeekBarTotalTime;
64     private long mDuration = 0;
65     private MediaController mMediaController;
66     private MediaMetadata mMediaMetadata;
67     private NotificationMediaManager mMediaManager;
68     private View mSeekBarView;
69     private Context mContext;
70     private MetricsLogger mMetricsLogger;
71     private boolean mIsViewVisible;
72 
73     @VisibleForTesting
74     protected SeekBar.OnSeekBarChangeListener mSeekListener =
75             new SeekBar.OnSeekBarChangeListener() {
76         @Override
77         public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
78         }
79 
80         @Override
81         public void onStartTrackingTouch(SeekBar seekBar) {
82         }
83 
84         @Override
85         public void onStopTrackingTouch(SeekBar seekBar) {
86             if (mMediaController != null) {
87                 mMediaController.getTransportControls().seekTo(mSeekBar.getProgress());
88                 mMetricsLogger.write(newLog(MetricsEvent.TYPE_UPDATE));
89             }
90         }
91     };
92 
93     private MediaNotificationView.VisibilityChangeListener mVisibilityListener =
94             new MediaNotificationView.VisibilityChangeListener() {
95         @Override
96         public void onAggregatedVisibilityChanged(boolean isVisible) {
97             mIsViewVisible = isVisible;
98             if (isVisible && mMediaController != null) {
99                 // Restart timer if we're currently playing and didn't already have one going
100                 PlaybackState state = mMediaController.getPlaybackState();
101                 if (state != null && state.getState() == PlaybackState.STATE_PLAYING
102                         && mSeekBarTimer == null && mSeekBarView != null
103                         && mSeekBarView.getVisibility() != View.GONE) {
104                     startTimer();
105                 }
106             } else {
107                 clearTimer();
108             }
109         }
110     };
111 
112     private View.OnAttachStateChangeListener mAttachStateListener =
113             new View.OnAttachStateChangeListener() {
114         @Override
115         public void onViewAttachedToWindow(View v) {
116         }
117 
118         @Override
119         public void onViewDetachedFromWindow(View v) {
120             mIsViewVisible = false;
121         }
122     };
123 
124     private MediaController.Callback mMediaCallback = new MediaController.Callback() {
125         @Override
126         public void onSessionDestroyed() {
127             clearTimer();
128             mMediaController.unregisterCallback(this);
129             if (mView instanceof MediaNotificationView) {
130                 ((MediaNotificationView) mView).removeVisibilityListener(mVisibilityListener);
131                 mView.removeOnAttachStateChangeListener(mAttachStateListener);
132             }
133         }
134 
135         @Override
136         public void onPlaybackStateChanged(@Nullable PlaybackState state) {
137             if (state == null) {
138                 return;
139             }
140 
141             if (state.getState() != PlaybackState.STATE_PLAYING) {
142                 // Update the UI once, in case playback info changed while we were paused
143                 updatePlaybackUi(state);
144                 clearTimer();
145             } else if (mSeekBarTimer == null && mSeekBarView != null
146                     && mSeekBarView.getVisibility() != View.GONE) {
147                 startTimer();
148             }
149         }
150 
151         @Override
152         public void onMetadataChanged(@Nullable MediaMetadata metadata) {
153             if (mMediaMetadata == null || !mMediaMetadata.equals(metadata)) {
154                 mMediaMetadata = metadata;
155                 updateDuration();
156             }
157         }
158     };
159 
NotificationMediaTemplateViewWrapper(Context ctx, View view, ExpandableNotificationRow row)160     protected NotificationMediaTemplateViewWrapper(Context ctx, View view,
161             ExpandableNotificationRow row) {
162         super(ctx, view, row);
163         mContext = ctx;
164         mMediaManager = Dependency.get(NotificationMediaManager.class);
165         mMetricsLogger = Dependency.get(MetricsLogger.class);
166 
167         if (mView instanceof MediaNotificationView) {
168             MediaNotificationView mediaView = (MediaNotificationView) mView;
169             mediaView.addVisibilityListener(mVisibilityListener);
170             mView.addOnAttachStateChangeListener(mAttachStateListener);
171         }
172     }
173 
resolveViews()174     private void resolveViews() {
175         mActions = mView.findViewById(com.android.internal.R.id.media_actions);
176         mIsViewVisible = mView.isShown();
177 
178         final MediaSession.Token token = mRow.getEntry().notification.getNotification().extras
179                 .getParcelable(Notification.EXTRA_MEDIA_SESSION);
180 
181         boolean showCompactSeekbar = mMediaManager.getShowCompactMediaSeekbar();
182         if (token == null || (COMPACT_MEDIA_TAG.equals(mView.getTag()) && !showCompactSeekbar)) {
183             if (mSeekBarView != null) {
184                 mSeekBarView.setVisibility(View.GONE);
185             }
186             return;
187         }
188 
189         // Check for existing media controller and clean up / create as necessary
190         boolean controllerUpdated = false;
191         if (mMediaController == null || !mMediaController.getSessionToken().equals(token)) {
192             if (mMediaController != null) {
193                 mMediaController.unregisterCallback(mMediaCallback);
194             }
195             mMediaController = new MediaController(mContext, token);
196             controllerUpdated = true;
197         }
198 
199         mMediaMetadata = mMediaController.getMetadata();
200         if (mMediaMetadata != null) {
201             long duration = mMediaMetadata.getLong(MediaMetadata.METADATA_KEY_DURATION);
202             if (duration <= 0) {
203                 // Don't include the seekbar if this is a livestream
204                 if (mSeekBarView != null && mSeekBarView.getVisibility() != View.GONE) {
205                     mSeekBarView.setVisibility(View.GONE);
206                     mMetricsLogger.write(newLog(MetricsEvent.TYPE_CLOSE));
207                     clearTimer();
208                 } else if (mSeekBarView == null && controllerUpdated) {
209                     // Only log if the controller changed, otherwise we would log multiple times for
210                     // the same notification when user pauses/resumes
211                     mMetricsLogger.write(newLog(MetricsEvent.TYPE_CLOSE));
212                 }
213                 return;
214             } else if (mSeekBarView != null && mSeekBarView.getVisibility() == View.GONE) {
215                 // Otherwise, make sure the seekbar is visible
216                 mSeekBarView.setVisibility(View.VISIBLE);
217                 mMetricsLogger.write(newLog(MetricsEvent.TYPE_OPEN));
218                 updateDuration();
219                 startTimer();
220             }
221         }
222 
223         // Inflate the seekbar template
224         ViewStub stub = mView.findViewById(R.id.notification_media_seekbar_container);
225         if (stub instanceof ViewStub) {
226             LayoutInflater layoutInflater = LayoutInflater.from(stub.getContext());
227             stub.setLayoutInflater(layoutInflater);
228             stub.setLayoutResource(R.layout.notification_material_media_seekbar);
229             mSeekBarView = stub.inflate();
230             mMetricsLogger.write(newLog(MetricsEvent.TYPE_OPEN));
231 
232             mSeekBar = mSeekBarView.findViewById(R.id.notification_media_progress_bar);
233             mSeekBar.setOnSeekBarChangeListener(mSeekListener);
234 
235             mSeekBarElapsedTime = mSeekBarView.findViewById(R.id.notification_media_elapsed_time);
236             mSeekBarTotalTime = mSeekBarView.findViewById(R.id.notification_media_total_time);
237 
238             if (mSeekBarTimer == null) {
239                 if (mMediaController != null && canSeekMedia(mMediaController.getPlaybackState())) {
240                     // Log initial state, since it will not be updated
241                     mMetricsLogger.write(newLog(MetricsEvent.TYPE_DETAIL, 1));
242                 } else {
243                     setScrubberVisible(false);
244                 }
245                 updateDuration();
246                 startTimer();
247                 mMediaController.registerCallback(mMediaCallback);
248             }
249         }
250         updateSeekBarTint(mSeekBarView);
251     }
252 
startTimer()253     private void startTimer() {
254         clearTimer();
255         if (mIsViewVisible) {
256             mSeekBarTimer = new Timer(true /* isDaemon */);
257             mSeekBarTimer.schedule(new TimerTask() {
258                 @Override
259                 public void run() {
260                     mHandler.post(mOnUpdateTimerTick);
261                 }
262             }, 0, PROGRESS_UPDATE_INTERVAL);
263         }
264     }
265 
clearTimer()266     private void clearTimer() {
267         if (mSeekBarTimer != null) {
268             mSeekBarTimer.cancel();
269             mSeekBarTimer.purge();
270             mSeekBarTimer = null;
271         }
272     }
273 
274     @Override
setRemoved()275     public void setRemoved() {
276         clearTimer();
277         if (mMediaController != null) {
278             mMediaController.unregisterCallback(mMediaCallback);
279         }
280         if (mView instanceof MediaNotificationView) {
281             ((MediaNotificationView) mView).removeVisibilityListener(mVisibilityListener);
282             mView.removeOnAttachStateChangeListener(mAttachStateListener);
283         }
284     }
285 
canSeekMedia(@ullable PlaybackState state)286     private boolean canSeekMedia(@Nullable PlaybackState state) {
287         if (state == null) {
288             return false;
289         }
290 
291         long actions = state.getActions();
292         return ((actions & PlaybackState.ACTION_SEEK_TO) != 0);
293     }
294 
setScrubberVisible(boolean isVisible)295     private void setScrubberVisible(boolean isVisible) {
296         if (mSeekBar == null || mSeekBar.isEnabled() == isVisible) {
297             return;
298         }
299 
300         mSeekBar.getThumb().setAlpha(isVisible ? 255 : 0);
301         mSeekBar.setEnabled(isVisible);
302         mMetricsLogger.write(newLog(MetricsEvent.TYPE_DETAIL, isVisible ? 1 : 0));
303     }
304 
updateDuration()305     private void updateDuration() {
306         if (mMediaMetadata != null && mSeekBar != null) {
307             long duration = mMediaMetadata.getLong(MediaMetadata.METADATA_KEY_DURATION);
308             if (mDuration != duration) {
309                 mDuration = duration;
310                 mSeekBar.setMax((int) mDuration);
311                 mSeekBarTotalTime.setText(millisecondsToTimeString(duration));
312             }
313         }
314     }
315 
316     protected final Runnable mOnUpdateTimerTick = new Runnable() {
317         @Override
318         public void run() {
319             if (mMediaController != null && mSeekBar != null) {
320                 PlaybackState playbackState = mMediaController.getPlaybackState();
321                 if (playbackState != null) {
322                     updatePlaybackUi(playbackState);
323                 } else {
324                     clearTimer();
325                 }
326             } else {
327                 clearTimer();
328             }
329         }
330     };
331 
updatePlaybackUi(PlaybackState state)332     private void updatePlaybackUi(PlaybackState state) {
333         if (mSeekBar == null || mSeekBarElapsedTime == null) {
334             return;
335         }
336 
337         long position = state.getPosition();
338         mSeekBar.setProgress((int) position);
339 
340         mSeekBarElapsedTime.setText(millisecondsToTimeString(position));
341 
342         // Update scrubber in case available actions have changed
343         setScrubberVisible(canSeekMedia(state));
344     }
345 
millisecondsToTimeString(long milliseconds)346     private String millisecondsToTimeString(long milliseconds) {
347         long seconds = milliseconds / 1000;
348         String text = DateUtils.formatElapsedTime(seconds);
349         return text;
350     }
351 
352     @Override
onContentUpdated(ExpandableNotificationRow row)353     public void onContentUpdated(ExpandableNotificationRow row) {
354         // Reinspect the notification. Before the super call, because the super call also updates
355         // the transformation types and we need to have our values set by then.
356         resolveViews();
357         super.onContentUpdated(row);
358     }
359 
updateSeekBarTint(View seekBarContainer)360     private void updateSeekBarTint(View seekBarContainer) {
361         if (seekBarContainer == null) {
362             return;
363         }
364 
365         if (this.getNotificationHeader() == null) {
366             return;
367         }
368 
369         int tintColor = getNotificationHeader().getOriginalIconColor();
370         mSeekBarElapsedTime.setTextColor(tintColor);
371         mSeekBarTotalTime.setTextColor(tintColor);
372         mSeekBarTotalTime.setShadowLayer(1.5f, 1.5f, 1.5f, mBackgroundColor);
373 
374         ColorStateList tintList = ColorStateList.valueOf(tintColor);
375         mSeekBar.setThumbTintList(tintList);
376         tintList = tintList.withAlpha(192); // 75%
377         mSeekBar.setProgressTintList(tintList);
378         tintList = tintList.withAlpha(128); // 50%
379         mSeekBar.setProgressBackgroundTintList(tintList);
380     }
381 
382     @Override
updateTransformedTypes()383     protected void updateTransformedTypes() {
384         // This also clears the existing types
385         super.updateTransformedTypes();
386         if (mActions != null) {
387             mTransformationHelper.addTransformedView(TransformableView.TRANSFORMING_VIEW_ACTIONS,
388                     mActions);
389         }
390     }
391 
392     @Override
isDimmable()393     public boolean isDimmable() {
394         return getCustomBackgroundColor() == 0;
395     }
396 
397     @Override
shouldClipToRounding(boolean topRounded, boolean bottomRounded)398     public boolean shouldClipToRounding(boolean topRounded, boolean bottomRounded) {
399         return true;
400     }
401 
402     /**
403      * Returns an initialized LogMaker for logging changes to the seekbar
404      * @return new LogMaker
405      */
newLog(int event)406     private LogMaker newLog(int event) {
407         String packageName = mRow.getEntry().notification.getPackageName();
408 
409         return new LogMaker(MetricsEvent.MEDIA_NOTIFICATION_SEEKBAR)
410                 .setType(event)
411                 .setPackageName(packageName);
412     }
413 
414     /**
415      * Returns an initialized LogMaker for logging changes with subtypes
416      * @return new LogMaker
417      */
newLog(int event, int subtype)418     private LogMaker newLog(int event, int subtype) {
419         String packageName = mRow.getEntry().notification.getPackageName();
420         return new LogMaker(MetricsEvent.MEDIA_NOTIFICATION_SEEKBAR)
421                 .setType(event)
422                 .setSubtype(subtype)
423                 .setPackageName(packageName);
424     }
425 }
426