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