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