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 */
16package com.android.car.cluster.sample;
17
18import static com.android.car.cluster.sample.DebugUtil.DEBUG;
19
20import android.annotation.Nullable;
21import android.app.Presentation;
22import android.car.cluster.renderer.NavigationRenderer;
23import android.car.navigation.CarNavigationInstrumentCluster;
24import android.car.navigation.CarNavigationStatusManager;
25import android.content.ComponentName;
26import android.content.ContentResolver;
27import android.content.Context;
28import android.content.Intent;
29import android.content.ServiceConnection;
30import android.content.res.Resources;
31import android.graphics.Bitmap;
32import android.graphics.Color;
33import android.hardware.display.DisplayManager;
34import android.media.MediaDescription;
35import android.media.MediaMetadata;
36import android.media.session.PlaybackState;
37import android.os.Handler;
38import android.os.IBinder;
39import android.os.Looper;
40import android.os.SystemClock;
41import android.os.UserHandle;
42import android.provider.Settings;
43import android.telecom.Call;
44import android.telecom.GatewayInfo;
45import android.text.TextUtils;
46import android.util.Log;
47import android.util.SparseArray;
48import android.view.Display;
49
50import com.android.car.cluster.sample.MediaStateMonitor.MediaStateListener;
51import com.android.car.cluster.sample.cards.MediaCard;
52import com.android.car.cluster.sample.cards.NavCard;
53
54import java.text.DecimalFormatSymbols;
55import java.text.NumberFormat;
56import java.util.Locale;
57import java.util.Objects;
58import java.util.Timer;
59import java.util.TimerTask;
60
61/**
62 * This class is responsible for subscribing to system events (such as call status, media status,
63 * etc.) and updating accordingly UI component {@link ClusterView}.
64 */
65/*package*/ class InstrumentClusterController {
66
67    private final static String TAG = DebugUtil.getTag(InstrumentClusterController.class);
68
69    private final Context mContext;
70    private final NavigationRenderer mNavigationRenderer;
71    private final SparseArray<String> mDistanceUnitNames = new SparseArray<>();
72
73    private ClusterView mClusterView;
74    private MediaStateMonitor mMediaStateMonitor;
75    private MediaStateListenerImpl mMediaStateListener;
76    private ClusterInCallService mInCallService;
77    private MessagingNotificationHandler mNotificationHandler;
78    private StatusBarNotificationListener mNotificationListener;
79    private RetriableServiceBinder mInCallServiceRetriableBinder;
80    private RetriableServiceBinder mNotificationServiceRetriableBinder;
81
82    InstrumentClusterController(Context context) {
83        mContext = context;
84        mNavigationRenderer = new NavigationRendererImpl(this);
85
86        init();
87    }
88
89    private void init() {
90        grantNotificationListenerPermissionsIfNecessary(mContext);
91
92        final Display display = getInstrumentClusterDisplay(mContext);
93        if (DEBUG) {
94            Log.d(TAG, "Instrument cluster display: " + display);
95        }
96        if (display == null) {
97            return;
98        }
99
100        initDistanceUnitNames(mContext);
101
102        mClusterView = new ClusterView(mContext);
103        Presentation presentation = new InstrumentClusterPresentation(mContext, display);
104        presentation.setContentView(mClusterView);
105
106        // To handle incoming messages
107        mNotificationHandler = new MessagingNotificationHandler(mClusterView);
108
109        mMediaStateListener = new MediaStateListenerImpl(this);
110        mMediaStateMonitor = new MediaStateMonitor(mContext, mMediaStateListener);
111
112        mInCallServiceRetriableBinder = new RetriableServiceBinder(
113                new Handler(Looper.getMainLooper()),
114                mContext,
115                ClusterInCallService.class,
116                ClusterInCallService.ACTION_LOCAL_BINDING,
117                mInCallServiceConnection);
118        mInCallServiceRetriableBinder.attemptToBind();
119
120        mNotificationServiceRetriableBinder = new RetriableServiceBinder(
121                new Handler(Looper.getMainLooper()),
122                mContext,
123                StatusBarNotificationListener.class,
124                StatusBarNotificationListener.ACTION_LOCAL_BINDING,
125                mNotificationListenerConnection);
126        mNotificationServiceRetriableBinder.attemptToBind();
127
128        // Show default card - weather
129        mClusterView.enqueueCard(mClusterView.createWeatherCard());
130
131        presentation.show();
132    }
133
134    NavigationRenderer getNavigationRenderer() {
135        return mNavigationRenderer;
136    }
137
138    private final ServiceConnection mInCallServiceConnection = new ServiceConnection() {
139        @Override
140        public void onServiceConnected(ComponentName name, IBinder binder) {
141            if (DEBUG) {
142                Log.d(TAG, "onServiceConnected, name: " + name + ", binder: " + binder);
143            }
144
145            mInCallService = ((ClusterInCallService.LocalBinder) binder).getService();
146            mInCallService.registerListener(mCallServiceListener);
147
148            // The InCallServiceImpl could be bound when we already have some active calls, let's
149            // notify UI about these calls.
150            for (Call call : mInCallService.getCalls()) {
151                mCallServiceListener.onStateChanged(call, call.getState());
152            }
153            mInCallServiceRetriableBinder = null;
154        }
155
156        @Override
157        public void onServiceDisconnected(ComponentName name) {
158            if (DEBUG) {
159                Log.d(TAG, "onServiceDisconnected, name: " + name);
160            }
161        }
162    };
163
164    private final ServiceConnection mNotificationListenerConnection = new ServiceConnection() {
165        @Override
166        public void onServiceConnected(ComponentName name, IBinder binder) {
167            if (DEBUG) {
168                Log.d(TAG, "onServiceConnected, name: " + name + ", binder: " + binder);
169            }
170
171            mNotificationListener = ((StatusBarNotificationListener.LocalBinder) binder)
172                    .getService();
173            mNotificationListener.setHandler(mNotificationHandler);
174
175            mNotificationServiceRetriableBinder = null;
176        }
177
178        @Override
179        public void onServiceDisconnected(ComponentName name) {
180            if (DEBUG) {
181                Log.d(TAG, "onServiceDisconnected, name: "+ name);
182            }
183        }
184    };
185
186    private final Call.Callback mCallServiceListener = new Call.Callback() {
187        @Override
188        public void onStateChanged(Call call, int state) {
189            if (DEBUG) {
190                Log.d(TAG, "onCallStateChanged, call: " + call + ", state: " + state);
191            }
192
193            runOnMain(() -> InstrumentClusterController.this.onCallStateChanged(call, state));
194        }
195    };
196
197    private String extractPhoneNumber(Call call) {
198        String number = "";
199        Call.Details details = call.getDetails();
200        if (details != null) {
201            GatewayInfo gatewayInfo = details.getGatewayInfo();
202
203            if (gatewayInfo != null) {
204                number = gatewayInfo.getOriginalAddress().getSchemeSpecificPart();
205            } else if (details.getHandle() != null) {
206                number = details.getHandle().getSchemeSpecificPart();
207            }
208        } else {
209            number = mContext.getResources().getString(R.string.unknown);
210        }
211
212        return number;
213    }
214
215    private void initDistanceUnitNames(Context context) {
216        mDistanceUnitNames.put(CarNavigationStatusManager.DISTANCE_METERS,
217                context.getString(R.string.nav_distance_units_meters));
218        mDistanceUnitNames.put(CarNavigationStatusManager.DISTANCE_KILOMETERS,
219                context.getString(R.string.nav_distance_units_kilometers));
220        mDistanceUnitNames.put(CarNavigationStatusManager.DISTANCE_FEET,
221                context.getString(R.string.nav_distance_units_ft));
222        mDistanceUnitNames.put(CarNavigationStatusManager.DISTANCE_MILES,
223                context.getString(R.string.nav_distance_units_miles));
224        mDistanceUnitNames.put(CarNavigationStatusManager.DISTANCE_YARDS,
225                context.getString(R.string.nav_distance_units_yards));
226    }
227
228    private void onCallStateChanged(Call call, int state) {
229        if (DEBUG) {
230            Log.d(TAG, "onCallStateChanged, call: " + call + ", state: " + state);
231        }
232
233        switch (state) {
234            case Call.STATE_ACTIVE: {
235                Call.Details details = call.getDetails();
236                if (details != null) {
237                    long duration = System.currentTimeMillis() - details.getConnectTimeMillis();
238                    mClusterView.handleCallConnected(SystemClock.elapsedRealtime() - duration);
239                }
240            } break;
241            case Call.STATE_CONNECTING: {
242
243            } break;
244            case Call.STATE_DISCONNECTING: {
245                mClusterView.handleCallDisconnected();
246            } break;
247            case Call.STATE_DIALING: {
248                String phoneNumber = extractPhoneNumber(call);
249                String displayName = TelecomUtils.getDisplayName(mContext, phoneNumber);
250                Bitmap image = TelecomUtils
251                        .getContactPhotoFromNumber(mContext.getContentResolver(), phoneNumber);
252                mClusterView.handleDialingCall(image, displayName);
253            } break;
254            case Call.STATE_DISCONNECTED: {
255                mClusterView.handleCallDisconnected();
256            } break;
257            case Call.STATE_HOLDING:
258                break;
259            case Call.STATE_NEW:
260                break;
261            case Call.STATE_RINGING: {
262                String phoneNumber = extractPhoneNumber(call);
263                String displayName = TelecomUtils.getDisplayName(mContext, phoneNumber);
264                Bitmap image = TelecomUtils
265                        .getContactPhotoFromNumber(mContext.getContentResolver(), phoneNumber);
266                if (image != null) {
267                    if (DEBUG) {
268                        Log.d(TAG, "Incoming call, contact image size: " + image.getWidth()
269                                + "x" + image.getHeight());
270                    }
271                }
272                mClusterView.handleIncomingCall(image, displayName);
273            } break;
274            default:
275                Log.w(TAG, "Unexpected call state: " + state + ", call : " + call);
276        }
277    }
278
279    private static void grantNotificationListenerPermissionsIfNecessary(Context context) {
280        ComponentName componentName = new ComponentName(context,
281                StatusBarNotificationListener.class);
282        String componentFlatten = componentName.flattenToString();
283
284        ContentResolver resolver = context.getContentResolver();
285        String grantedComponents = Settings.Secure.getString(resolver,
286                Settings.Secure.ENABLED_NOTIFICATION_LISTENERS);
287
288        if (grantedComponents != null) {
289            String[] allowed = grantedComponents.split(":");
290            for (String s : allowed) {
291                if (s.equals(componentFlatten)) {
292                    if (DEBUG) {
293                        Log.d(TAG, "Notification listener permission granted.");
294                    }
295                    return;  // Permission already granted.
296                }
297            }
298        }
299
300        if (DEBUG) {
301            Log.d(TAG, "Granting notification listener permission.");
302        }
303        Settings.Secure.putString(resolver,
304                Settings.Secure.ENABLED_NOTIFICATION_LISTENERS,
305                grantedComponents + ":" + componentFlatten);
306
307    }
308
309    /* package */ void onDestroy() {
310        if (mMediaStateMonitor != null) {
311            mMediaStateMonitor.release();
312            mMediaStateMonitor = null;
313        }
314        if (mMediaStateListener != null) {
315            mMediaStateListener.release();
316            mMediaStateListener = null;
317        }
318        if (mInCallService != null) {
319            mContext.unbindService(mInCallServiceConnection);
320            mInCallService = null;
321        }
322        if (mNotificationListener != null) {
323            mContext.unbindService(mNotificationListenerConnection);
324            mNotificationListener = null;
325        }
326        if (mInCallServiceRetriableBinder != null) {
327            mInCallServiceRetriableBinder.release();
328            mInCallServiceRetriableBinder = null;
329        }
330        if (mNotificationServiceRetriableBinder != null) {
331            mNotificationServiceRetriableBinder.release();
332            mNotificationServiceRetriableBinder = null;
333        }
334    }
335
336    private static Display getInstrumentClusterDisplay(Context context) {
337        DisplayManager displayManager = context.getSystemService(DisplayManager.class);
338        Display[] displays = displayManager.getDisplays();
339
340        if (DEBUG) {
341            Log.d(TAG, "There are currently " + displays.length + " displays connected.");
342            for (Display display : displays) {
343                Log.d(TAG, "  " + display);
344            }
345        }
346
347        if (displays.length > 1) {
348            // TODO: Put this into settings?
349            return displays[displays.length - 1];
350        }
351        return null;
352    }
353
354    private static void runOnMain(Runnable runnable) {
355        new Handler(Looper.getMainLooper()).post(runnable);
356    }
357
358    private static class MediaStateListenerImpl implements MediaStateListener {
359        private final Timer mTimer = new Timer("ClusterMediaProgress");
360        private final ClusterView mClusterView;
361
362        private MediaData mCurrentMedia;
363        private MediaAppInfo mMediaAppInfo;
364        private MediaCard mCard;
365        private PlaybackState mPlaybackState;
366        private TimerTask mTimerTask;
367
368        MediaStateListenerImpl(InstrumentClusterController renderer) {
369            mClusterView = renderer.mClusterView;
370        }
371
372        void release() {
373            if (mTimerTask != null) {
374                mTimerTask.cancel();
375                mTimerTask = null;
376            }
377        }
378
379        @Override
380        public void onPlaybackStateChanged(final PlaybackState playbackState) {
381            if (DEBUG) {
382                Log.d(TAG, "onPlaybackStateChanged, playbackState: " + playbackState);
383            }
384
385            if (mTimerTask != null) {
386                mTimerTask.cancel();
387                mTimerTask = null;
388            }
389
390            if (playbackState != null) {
391                if ((playbackState.getState() == PlaybackState.STATE_PLAYING
392                            || playbackState.getState() == PlaybackState.STATE_BUFFERING)) {
393                    mPlaybackState = playbackState;
394
395                    if (mCurrentMedia != null) {
396                        showMediaCardIfNecessary(mCurrentMedia);
397
398                        if (mCurrentMedia.duration > 0) {
399                            startTrackProgressTimer();
400                        }
401                    }
402                } else if (playbackState.getState() == PlaybackState.STATE_STOPPED
403                        || playbackState.getState() == PlaybackState.STATE_ERROR
404                        || playbackState.getState() == PlaybackState.STATE_NONE) {
405                    hideMediaCard();
406                }
407            } else {
408                hideMediaCard();
409            }
410
411        }
412
413        private void startTrackProgressTimer() {
414            mTimerTask = new TimerTask() {
415                @Override
416                public void run() {
417                    runOnMain(() -> {
418                        if (mPlaybackState == null || mCard == null) {
419                            return;
420                        }
421                        long trackStarted = mPlaybackState.getLastPositionUpdateTime()
422                                - mPlaybackState.getPosition();
423                        long trackDuration = mCurrentMedia == null ? 0 : mCurrentMedia.duration;
424
425                        long currentTime = SystemClock.elapsedRealtime();
426                        long progressMs = (currentTime - trackStarted);
427                        if (trackDuration > 0) {
428                            mCard.setProgress((int)((progressMs * 100) / trackDuration));
429                        }
430                    });
431                }
432            };
433
434            mTimer.scheduleAtFixedRate(mTimerTask, 0, 1000);
435        }
436
437
438        @Override
439        public void onMetadataChanged(MediaMetadata metadata) {
440            if (DEBUG) {
441                Log.d(TAG, "onMetadataChanged: " + metadata);
442            }
443            MediaData data = MediaData.createFromMetadata(metadata);
444            if (data == null) {
445                hideMediaCard();
446            }
447            mCurrentMedia = data;
448        }
449
450        private void hideMediaCard() {
451            if (DEBUG) {
452                Log.d(TAG, "hideMediaCard");
453            }
454
455            if (mCard != null) {
456                mClusterView.removeCard(mCard);
457                mCard = null;
458            }
459
460            // Remove all existing media cards if any.
461            MediaCard mediaCard;
462            do {
463                mediaCard = mClusterView.getCardOrNull(MediaCard.class);
464                if (mediaCard != null) {
465                    mClusterView.removeCard(mediaCard);
466                }
467            } while (mediaCard != null);
468        }
469
470        private void showMediaCardIfNecessary(MediaData data) {
471            if (!needToCreateMediaCard(data)) {
472                return;
473            }
474
475            int accentColor = mMediaAppInfo == null
476                    ? Color.GRAY : mMediaAppInfo.getMediaClientAccentColor();
477
478            mCard = mClusterView.createMediaCard(
479                    data.albumCover, data.title, data.subtitle, accentColor);
480            if (data.duration <= 0) {
481                mCard.setProgress(100); // unknown position
482            } else {
483                mCard.setProgress(0);
484            }
485            mClusterView.enqueueCard(mCard);
486        }
487
488        private boolean needToCreateMediaCard(MediaData data) {
489            return (mCard == null)
490                    || !Objects.equals(mCard.getTitle(), data.title)
491                    || !Objects.equals(mCard.getSubtitle(), data.subtitle);
492        }
493
494        @Override
495        public void onMediaAppChanged(MediaAppInfo mediaAppInfo) {
496            mMediaAppInfo = mediaAppInfo;
497        }
498
499        private static class MediaData {
500            final Bitmap albumCover;
501            final String subtitle;
502            final String title;
503            final long duration;
504
505            private MediaData(MediaMetadata metadata) {
506                MediaDescription mediaDescription = metadata.getDescription();
507                title = charSequenceToString(mediaDescription.getTitle());
508                subtitle = charSequenceToString(mediaDescription.getSubtitle());
509                albumCover = mediaDescription.getIconBitmap();
510                duration = metadata.getLong(MediaMetadata.METADATA_KEY_DURATION);
511            }
512
513            static MediaData createFromMetadata(MediaMetadata metadata) {
514                return  metadata == null ? null : new MediaData(metadata);
515            }
516
517            private static String charSequenceToString(@Nullable CharSequence cs) {
518                return cs == null ? null : String.valueOf(cs);
519            }
520
521            @Override
522            public String toString() {
523                return "MediaData{" +
524                        "albumCover=" + albumCover +
525                        ", subtitle='" + subtitle + '\'' +
526                        ", title='" + title + '\'' +
527                        ", duration=" + duration +
528                        '}';
529            }
530        }
531    }
532
533    private static class NavigationRendererImpl extends NavigationRenderer {
534
535        private final InstrumentClusterController mController;
536
537        private ClusterView mClusterView;
538        private Resources mResources;
539
540        private NavCard mNavCard;
541
542        NavigationRendererImpl(InstrumentClusterController controller) {
543            mController = controller;
544        }
545
546        @Override
547        public CarNavigationInstrumentCluster getNavigationProperties() {
548            if (DEBUG) {
549                Log.d(TAG, "getNavigationProperties");
550            }
551            return CarNavigationInstrumentCluster.createCustomImageCluster(
552                    1000, /* 1 Hz*/
553                    64,   /* image width */
554                    64,   /* image height */
555                    32);  /* color depth */
556        }
557
558        @Override
559        public void onStartNavigation() {
560            if (DEBUG) {
561                Log.d(TAG, "onStartNavigation");
562            }
563            mClusterView = mController.mClusterView;
564            mResources = mController.mContext.getResources();
565            mNavCard = mClusterView.createNavCard();
566        }
567
568        @Override
569        public void onStopNavigation() {
570            if (DEBUG) {
571                Log.d(TAG, "onStopNavigation");
572            }
573
574            if (mNavCard != null) {
575                mNavCard.removeGracefully();
576                mNavCard = null;
577            }
578        }
579
580        @Override
581        public void onNextTurnChanged(int event, CharSequence eventName, int turnAngle,
582                int turnNumber, Bitmap image, int turnSide) {
583            if (DEBUG) {
584                Log.d(TAG, "onNextTurnChanged, eventName: " + eventName + ", image: " + image +
585                        (image == null ? "" : ", size: "
586                                + image.getWidth() + "x" + image.getHeight()));
587            }
588            mNavCard.setManeuverImage(BitmapUtils.generateNavManeuverIcon(
589                    (int) mResources.getDimension(R.dimen.card_icon_size),
590                    mResources.getColor(R.color.maps_background, null),
591                    image));
592            mNavCard.setStreet(eventName);
593            if (!mClusterView.cardExists(mNavCard)) {
594                mClusterView.enqueueCard(mNavCard);
595            }
596        }
597
598        @Override
599        public void onNextTurnDistanceChanged(int meters, int timeSeconds,
600                int displayDistanceMillis, int distanceUnit) {
601            if (DEBUG) {
602                Log.d(TAG, "onNextTurnDistanceChanged, distanceMeters: " + meters
603                        + ", timeSeconds: " + timeSeconds
604                        + ", displayDistanceMillis: " + displayDistanceMillis
605                        + ", DistanceUnit: " + distanceUnit);
606            }
607
608            int remainder = displayDistanceMillis % 1000;
609            String decimalPart = (remainder != 0)
610                    ? String.format("%c%d",
611                                    DecimalFormatSymbols.getInstance().getDecimalSeparator(),
612                                    remainder)
613                    : "";
614
615            String distanceToDisplay = (displayDistanceMillis / 1000) + decimalPart;
616            String unitsToDisplay = mController.mDistanceUnitNames.get(distanceUnit);
617
618            mNavCard.setDistanceToNextManeuver(distanceToDisplay, unitsToDisplay);
619        }
620    }
621
622    /**
623     * Services might not be ready for binding. This class will retry binding after short interval
624     * if previous binding failed.
625     */
626    private static class RetriableServiceBinder {
627        private static final long RETRY_INTERVAL_MS = 500;
628        private static final long MAX_RETRY = 30;
629
630        private Handler mHandler;
631        private final Context mContext;
632        private final Intent mIntent;
633        private final ServiceConnection mConnection;
634
635        private long mAttemptsLeft = MAX_RETRY;
636
637        private final Runnable mBindRunnable = () -> attemptToBind();
638
639        RetriableServiceBinder(Handler handler, Context context, Class<?> cls, String action,
640                ServiceConnection connection) {
641            mHandler = handler;
642            mContext = context;
643            mIntent = new Intent(mContext, cls);
644            mIntent.setAction(action);
645            mConnection = connection;
646        }
647
648        void release() {
649            mHandler.removeCallbacks(mBindRunnable);
650        }
651
652        void attemptToBind() {
653            boolean bound = mContext.bindServiceAsUser(mIntent,
654                    mConnection, Context.BIND_AUTO_CREATE, UserHandle.CURRENT_OR_SELF);
655
656            if (!bound && --mAttemptsLeft > 0) {
657                mHandler.postDelayed(mBindRunnable, RETRY_INTERVAL_MS);
658            } else if (!bound) {
659                Log.e(TAG, "Gave up to bind to a service: " + mIntent.getComponent() + " after "
660                        + MAX_RETRY + " retries.");
661            }
662        }
663    }
664}
665