1 /* 2 * Copyright (C) 2017 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.data; 18 19 import android.annotation.TargetApi; 20 import android.content.ContentResolver; 21 import android.content.ContentUris; 22 import android.content.Context; 23 import android.database.Cursor; 24 import android.database.SQLException; 25 import android.graphics.Bitmap; 26 import android.graphics.drawable.BitmapDrawable; 27 import android.graphics.drawable.Drawable; 28 import android.media.tv.TvContract; 29 import android.net.Uri; 30 import android.os.AsyncTask; 31 import android.os.Build; 32 import android.support.annotation.IntDef; 33 import android.support.annotation.MainThread; 34 import android.support.media.tv.ChannelLogoUtils; 35 import android.support.media.tv.PreviewProgram; 36 import android.util.Log; 37 import android.util.Pair; 38 import com.android.tv.R; 39 import com.android.tv.common.util.PermissionUtils; 40 import java.lang.annotation.Retention; 41 import java.lang.annotation.RetentionPolicy; 42 import java.util.HashMap; 43 import java.util.Map; 44 import java.util.Set; 45 import java.util.concurrent.CopyOnWriteArraySet; 46 47 /** Class to manage the preview data. */ 48 @TargetApi(Build.VERSION_CODES.O) 49 @MainThread 50 public class PreviewDataManager { 51 private static final String TAG = "PreviewDataManager"; 52 private static final boolean DEBUG = false; 53 54 /** Invalid preview channel ID. */ 55 public static final long INVALID_PREVIEW_CHANNEL_ID = -1; 56 57 @IntDef({TYPE_DEFAULT_PREVIEW_CHANNEL, TYPE_RECORDED_PROGRAM_PREVIEW_CHANNEL}) 58 @Retention(RetentionPolicy.SOURCE) 59 public @interface PreviewChannelType {} 60 61 /** Type of default preview channel */ 62 public static final int TYPE_DEFAULT_PREVIEW_CHANNEL = 1; 63 /** Type of recorded program channel */ 64 public static final int TYPE_RECORDED_PROGRAM_PREVIEW_CHANNEL = 2; 65 66 private final Context mContext; 67 private final ContentResolver mContentResolver; 68 private boolean mLoadFinished; 69 private PreviewData mPreviewData = new PreviewData(); 70 private final Set<PreviewDataListener> mPreviewDataListeners = new CopyOnWriteArraySet<>(); 71 72 private QueryPreviewDataTask mQueryPreviewTask; 73 private final Map<Long, CreatePreviewChannelTask> mCreatePreviewChannelTasks = new HashMap<>(); 74 private final Map<Long, UpdatePreviewProgramTask> mUpdatePreviewProgramTasks = new HashMap<>(); 75 76 private final int mPreviewChannelLogoWidth; 77 private final int mPreviewChannelLogoHeight; 78 79 public PreviewDataManager(Context context) { 80 mContext = context.getApplicationContext(); 81 mContentResolver = context.getContentResolver(); 82 mPreviewChannelLogoWidth = 83 mContext.getResources().getDimensionPixelSize(R.dimen.preview_channel_logo_width); 84 mPreviewChannelLogoHeight = 85 mContext.getResources().getDimensionPixelSize(R.dimen.preview_channel_logo_height); 86 } 87 88 /** Starts the preview data manager. */ 89 public void start() { 90 if (mQueryPreviewTask == null) { 91 mQueryPreviewTask = new QueryPreviewDataTask(); 92 mQueryPreviewTask.execute(); 93 } 94 } 95 96 /** Stops the preview data manager. */ 97 public void stop() { 98 if (mQueryPreviewTask != null) { 99 mQueryPreviewTask.cancel(true); 100 } 101 for (CreatePreviewChannelTask createPreviewChannelTask : 102 mCreatePreviewChannelTasks.values()) { 103 createPreviewChannelTask.cancel(true); 104 } 105 for (UpdatePreviewProgramTask updatePreviewProgramTask : 106 mUpdatePreviewProgramTasks.values()) { 107 updatePreviewProgramTask.cancel(true); 108 } 109 110 mQueryPreviewTask = null; 111 mCreatePreviewChannelTasks.clear(); 112 mUpdatePreviewProgramTasks.clear(); 113 } 114 115 /** Gets preview channel ID from the preview channel type. */ 116 public @PreviewChannelType long getPreviewChannelId(long previewChannelType) { 117 return mPreviewData.getPreviewChannelId(previewChannelType); 118 } 119 120 /** Creates default preview channel. */ 121 public void createDefaultPreviewChannel( 122 OnPreviewChannelCreationResultListener onPreviewChannelCreationResultListener) { 123 createPreviewChannel(TYPE_DEFAULT_PREVIEW_CHANNEL, onPreviewChannelCreationResultListener); 124 } 125 126 /** Creates a preview channel for specific channel type. */ 127 public void createPreviewChannel( 128 @PreviewChannelType long previewChannelType, 129 OnPreviewChannelCreationResultListener onPreviewChannelCreationResultListener) { 130 CreatePreviewChannelTask currentRunningCreateTask = 131 mCreatePreviewChannelTasks.get(previewChannelType); 132 if (currentRunningCreateTask == null) { 133 CreatePreviewChannelTask createPreviewChannelTask = 134 new CreatePreviewChannelTask(previewChannelType); 135 createPreviewChannelTask.addOnPreviewChannelCreationResultListener( 136 onPreviewChannelCreationResultListener); 137 createPreviewChannelTask.execute(); 138 mCreatePreviewChannelTasks.put(previewChannelType, createPreviewChannelTask); 139 } else { 140 currentRunningCreateTask.addOnPreviewChannelCreationResultListener( 141 onPreviewChannelCreationResultListener); 142 } 143 } 144 145 /** Returns {@code true} if the preview data is loaded. */ 146 public boolean isLoadFinished() { 147 return mLoadFinished; 148 } 149 150 /** Adds listener. */ 151 public void addListener(PreviewDataListener previewDataListener) { 152 mPreviewDataListeners.add(previewDataListener); 153 } 154 155 /** Removes listener. */ 156 public void removeListener(PreviewDataListener previewDataListener) { 157 mPreviewDataListeners.remove(previewDataListener); 158 } 159 160 /** Updates the preview programs table for a specific preview channel. */ 161 public void updatePreviewProgramsForChannel( 162 long previewChannelId, 163 Set<PreviewProgramContent> programs, 164 PreviewDataListener previewDataListener) { 165 UpdatePreviewProgramTask currentRunningUpdateTask = 166 mUpdatePreviewProgramTasks.get(previewChannelId); 167 if (currentRunningUpdateTask != null 168 && currentRunningUpdateTask.getPrograms().equals(programs)) { 169 currentRunningUpdateTask.addPreviewDataListener(previewDataListener); 170 return; 171 } 172 UpdatePreviewProgramTask updatePreviewProgramTask = 173 new UpdatePreviewProgramTask(previewChannelId, programs); 174 updatePreviewProgramTask.addPreviewDataListener(previewDataListener); 175 if (currentRunningUpdateTask != null) { 176 currentRunningUpdateTask.cancel(true); 177 currentRunningUpdateTask.saveStatus(); 178 updatePreviewProgramTask.addPreviewDataListeners( 179 currentRunningUpdateTask.getPreviewDataListeners()); 180 } 181 updatePreviewProgramTask.execute(); 182 mUpdatePreviewProgramTasks.put(previewChannelId, updatePreviewProgramTask); 183 } 184 185 private void notifyPreviewDataLoadFinished() { 186 for (PreviewDataListener l : mPreviewDataListeners) { 187 l.onPreviewDataLoadFinished(); 188 } 189 } 190 191 public interface PreviewDataListener { 192 /** Called when the preview data is loaded. */ 193 void onPreviewDataLoadFinished(); 194 195 /** Called when the preview data is updated. */ 196 void onPreviewDataUpdateFinished(); 197 } 198 199 public interface OnPreviewChannelCreationResultListener { 200 /** 201 * Called when the creation of preview channel is finished. 202 * 203 * @param createdPreviewChannelId The preview channel ID if created successfully, otherwise 204 * it's {@value #INVALID_PREVIEW_CHANNEL_ID}. 205 */ 206 void onPreviewChannelCreationResult(long createdPreviewChannelId); 207 } 208 209 private final class QueryPreviewDataTask extends AsyncTask<Void, Void, PreviewData> { 210 private final String PARAM_PREVIEW = "preview"; 211 private final String mChannelSelection = TvContract.Channels.COLUMN_PACKAGE_NAME + "=?"; 212 213 @Override 214 protected PreviewData doInBackground(Void... voids) { 215 // Query preview channels and programs. 216 if (DEBUG) Log.d(TAG, "QueryPreviewDataTask.doInBackground"); 217 PreviewData previewData = new PreviewData(); 218 try { 219 Uri previewChannelsUri = 220 PreviewDataUtils.addQueryParamToUri( 221 TvContract.Channels.CONTENT_URI, 222 new Pair<>(PARAM_PREVIEW, String.valueOf(true))); 223 String packageName = mContext.getPackageName(); 224 if (PermissionUtils.hasAccessAllEpg(mContext)) { 225 try (Cursor cursor = 226 mContentResolver.query( 227 previewChannelsUri, 228 android.support.media.tv.Channel.PROJECTION, 229 mChannelSelection, 230 new String[] {packageName}, 231 null)) { 232 if (cursor != null) { 233 while (cursor.moveToNext()) { 234 android.support.media.tv.Channel previewChannel = 235 android.support.media.tv.Channel.fromCursor(cursor); 236 Long previewChannelType = previewChannel.getInternalProviderFlag1(); 237 if (previewChannelType != null) { 238 previewData.addPreviewChannelId( 239 previewChannelType, previewChannel.getId()); 240 } 241 } 242 } 243 } 244 } else { 245 try (Cursor cursor = 246 mContentResolver.query( 247 previewChannelsUri, 248 android.support.media.tv.Channel.PROJECTION, 249 null, 250 null, 251 null)) { 252 if (cursor != null) { 253 while (cursor.moveToNext()) { 254 android.support.media.tv.Channel previewChannel = 255 android.support.media.tv.Channel.fromCursor(cursor); 256 Long previewChannelType = previewChannel.getInternalProviderFlag1(); 257 if (packageName.equals(previewChannel.getPackageName()) 258 && previewChannelType != null) { 259 previewData.addPreviewChannelId( 260 previewChannelType, previewChannel.getId()); 261 } 262 } 263 } 264 } 265 } 266 267 for (long previewChannelId : previewData.getAllPreviewChannelIds().values()) { 268 Uri previewProgramsUriForPreviewChannel = 269 TvContract.buildPreviewProgramsUriForChannel(previewChannelId); 270 try (Cursor previewProgramCursor = 271 mContentResolver.query( 272 previewProgramsUriForPreviewChannel, 273 PreviewProgram.PROJECTION, 274 null, 275 null, 276 null)) { 277 if (previewProgramCursor != null) { 278 while (previewProgramCursor.moveToNext()) { 279 PreviewProgram previewProgram = 280 PreviewProgram.fromCursor(previewProgramCursor); 281 previewData.addPreviewProgram(previewProgram); 282 } 283 } 284 } 285 } 286 } catch (SQLException e) { 287 Log.w(TAG, "Unable to get preview data", e); 288 } 289 return previewData; 290 } 291 292 @Override 293 protected void onPostExecute(PreviewData result) { 294 super.onPostExecute(result); 295 if (mQueryPreviewTask == this) { 296 mQueryPreviewTask = null; 297 mPreviewData = new PreviewData(result); 298 mLoadFinished = true; 299 notifyPreviewDataLoadFinished(); 300 } 301 } 302 } 303 304 private final class CreatePreviewChannelTask extends AsyncTask<Void, Void, Long> { 305 private final long mPreviewChannelType; 306 private Set<OnPreviewChannelCreationResultListener> 307 mOnPreviewChannelCreationResultListeners = new CopyOnWriteArraySet<>(); 308 309 public CreatePreviewChannelTask(long previewChannelType) { 310 mPreviewChannelType = previewChannelType; 311 } 312 313 public void addOnPreviewChannelCreationResultListener( 314 OnPreviewChannelCreationResultListener onPreviewChannelCreationResultListener) { 315 if (onPreviewChannelCreationResultListener != null) { 316 mOnPreviewChannelCreationResultListeners.add( 317 onPreviewChannelCreationResultListener); 318 } 319 } 320 321 @Override 322 protected Long doInBackground(Void... params) { 323 if (DEBUG) Log.d(TAG, "CreatePreviewChannelTask.doInBackground"); 324 long previewChannelId; 325 try { 326 Uri channelUri = 327 mContentResolver.insert( 328 TvContract.Channels.CONTENT_URI, 329 PreviewDataUtils.createPreviewChannel(mContext, mPreviewChannelType) 330 .toContentValues()); 331 if (channelUri != null) { 332 previewChannelId = ContentUris.parseId(channelUri); 333 } else { 334 Log.e(TAG, "Fail to insert preview channel"); 335 return INVALID_PREVIEW_CHANNEL_ID; 336 } 337 } catch (UnsupportedOperationException | NumberFormatException e) { 338 Log.e(TAG, "Fail to get channel ID"); 339 return INVALID_PREVIEW_CHANNEL_ID; 340 } 341 Drawable appIcon = mContext.getApplicationInfo().loadIcon(mContext.getPackageManager()); 342 if (appIcon != null && appIcon instanceof BitmapDrawable) { 343 ChannelLogoUtils.storeChannelLogo( 344 mContext, 345 previewChannelId, 346 Bitmap.createScaledBitmap( 347 ((BitmapDrawable) appIcon).getBitmap(), 348 mPreviewChannelLogoWidth, 349 mPreviewChannelLogoHeight, 350 false)); 351 } 352 return previewChannelId; 353 } 354 355 @Override 356 protected void onPostExecute(Long result) { 357 super.onPostExecute(result); 358 if (result != INVALID_PREVIEW_CHANNEL_ID) { 359 mPreviewData.addPreviewChannelId(mPreviewChannelType, result); 360 } 361 for (OnPreviewChannelCreationResultListener onPreviewChannelCreationResultListener : 362 mOnPreviewChannelCreationResultListeners) { 363 onPreviewChannelCreationResultListener.onPreviewChannelCreationResult(result); 364 } 365 mCreatePreviewChannelTasks.remove(mPreviewChannelType); 366 } 367 } 368 369 /** 370 * Updates the whole data which belongs to the package in preview programs table for a specific 371 * preview channel with a set of {@link PreviewProgramContent}. 372 */ 373 private final class UpdatePreviewProgramTask extends AsyncTask<Void, Void, Void> { 374 private long mPreviewChannelId; 375 private Set<PreviewProgramContent> mPrograms; 376 private Map<Long, Long> mCurrentProgramId2PreviewProgramId; 377 private Set<PreviewDataListener> mPreviewDataListeners = new CopyOnWriteArraySet<>(); 378 379 public UpdatePreviewProgramTask( 380 long previewChannelId, Set<PreviewProgramContent> programs) { 381 mPreviewChannelId = previewChannelId; 382 mPrograms = programs; 383 if (mPreviewData.getPreviewProgramIds(previewChannelId) == null) { 384 mCurrentProgramId2PreviewProgramId = new HashMap<>(); 385 } else { 386 mCurrentProgramId2PreviewProgramId = 387 new HashMap<>(mPreviewData.getPreviewProgramIds(previewChannelId)); 388 } 389 } 390 391 public void addPreviewDataListener(PreviewDataListener previewDataListener) { 392 if (previewDataListener != null) { 393 mPreviewDataListeners.add(previewDataListener); 394 } 395 } 396 397 public void addPreviewDataListeners(Set<PreviewDataListener> previewDataListeners) { 398 if (previewDataListeners != null) { 399 mPreviewDataListeners.addAll(previewDataListeners); 400 } 401 } 402 403 public Set<PreviewProgramContent> getPrograms() { 404 return mPrograms; 405 } 406 407 public Set<PreviewDataListener> getPreviewDataListeners() { 408 return mPreviewDataListeners; 409 } 410 411 @Override 412 protected Void doInBackground(Void... params) { 413 if (DEBUG) Log.d(TAG, "UpdatePreviewProgamTask.doInBackground"); 414 Map<Long, Long> uncheckedPrograms = new HashMap<>(mCurrentProgramId2PreviewProgramId); 415 for (PreviewProgramContent program : mPrograms) { 416 if (isCancelled()) { 417 return null; 418 } 419 Long existingPreviewProgramId = uncheckedPrograms.remove(program.getId()); 420 if (existingPreviewProgramId != null) { 421 if (DEBUG) 422 Log.d( 423 TAG, 424 "Preview program " 425 + existingPreviewProgramId 426 + " " 427 + "already exists for program " 428 + program.getId()); 429 continue; 430 } 431 try { 432 Uri programUri = 433 mContentResolver.insert( 434 TvContract.PreviewPrograms.CONTENT_URI, 435 PreviewDataUtils.createPreviewProgramFromContent(program) 436 .toContentValues()); 437 if (programUri != null) { 438 long previewProgramId = ContentUris.parseId(programUri); 439 mCurrentProgramId2PreviewProgramId.put(program.getId(), previewProgramId); 440 if (DEBUG) Log.d(TAG, "Add new preview program " + previewProgramId); 441 } else { 442 Log.e(TAG, "Fail to insert preview program"); 443 } 444 } catch (Exception e) { 445 Log.e(TAG, "Fail to get preview program ID"); 446 } 447 } 448 449 for (Long key : uncheckedPrograms.keySet()) { 450 if (isCancelled()) { 451 return null; 452 } 453 try { 454 if (DEBUG) Log.d(TAG, "Remove preview program " + uncheckedPrograms.get(key)); 455 mContentResolver.delete( 456 TvContract.buildPreviewProgramUri(uncheckedPrograms.get(key)), 457 null, 458 null); 459 mCurrentProgramId2PreviewProgramId.remove(key); 460 } catch (Exception e) { 461 Log.e(TAG, "Fail to remove preview program " + uncheckedPrograms.get(key)); 462 } 463 } 464 return null; 465 } 466 467 @Override 468 protected void onPostExecute(Void result) { 469 super.onPostExecute(result); 470 mPreviewData.setPreviewProgramIds( 471 mPreviewChannelId, mCurrentProgramId2PreviewProgramId); 472 mUpdatePreviewProgramTasks.remove(mPreviewChannelId); 473 for (PreviewDataListener previewDataListener : mPreviewDataListeners) { 474 previewDataListener.onPreviewDataUpdateFinished(); 475 } 476 } 477 478 public void saveStatus() { 479 mPreviewData.setPreviewProgramIds( 480 mPreviewChannelId, mCurrentProgramId2PreviewProgramId); 481 } 482 } 483 484 /** Class to store the query result of preview data. */ 485 private static final class PreviewData { 486 private Map<Long, Long> mPreviewChannelType2Id = new HashMap<>(); 487 private Map<Long, Map<Long, Long>> mProgramId2PreviewProgramId = new HashMap<>(); 488 489 PreviewData() { 490 mPreviewChannelType2Id = new HashMap<>(); 491 mProgramId2PreviewProgramId = new HashMap<>(); 492 } 493 494 PreviewData(PreviewData previewData) { 495 mPreviewChannelType2Id = new HashMap<>(previewData.mPreviewChannelType2Id); 496 mProgramId2PreviewProgramId = new HashMap<>(previewData.mProgramId2PreviewProgramId); 497 } 498 499 public void addPreviewProgram(PreviewProgram previewProgram) { 500 long previewChannelId = previewProgram.getChannelId(); 501 Map<Long, Long> programId2PreviewProgram = 502 mProgramId2PreviewProgramId.get(previewChannelId); 503 if (programId2PreviewProgram == null) { 504 programId2PreviewProgram = new HashMap<>(); 505 } 506 mProgramId2PreviewProgramId.put(previewChannelId, programId2PreviewProgram); 507 if (previewProgram.getInternalProviderId() != null) { 508 programId2PreviewProgram.put( 509 Long.parseLong(previewProgram.getInternalProviderId()), 510 previewProgram.getId()); 511 } 512 } 513 514 public @PreviewChannelType long getPreviewChannelId(long previewChannelType) { 515 Long result = mPreviewChannelType2Id.get(previewChannelType); 516 return result == null ? INVALID_PREVIEW_CHANNEL_ID : result; 517 } 518 519 public Map<Long, Long> getAllPreviewChannelIds() { 520 return mPreviewChannelType2Id; 521 } 522 523 public void addPreviewChannelId(long previewChannelType, long previewChannelId) { 524 mPreviewChannelType2Id.put(previewChannelType, previewChannelId); 525 } 526 527 public void removePreviewChannelId(long previewChannelType) { 528 mPreviewChannelType2Id.remove(previewChannelType); 529 } 530 531 public void removePreviewChannel(long previewChannelId) { 532 removePreviewChannelId(previewChannelId); 533 removePreviewProgramIds(previewChannelId); 534 } 535 536 public Map<Long, Long> getPreviewProgramIds(long previewChannelId) { 537 return mProgramId2PreviewProgramId.get(previewChannelId); 538 } 539 540 public Map<Long, Map<Long, Long>> getAllPreviewProgramIds() { 541 return mProgramId2PreviewProgramId; 542 } 543 544 public void setPreviewProgramIds( 545 long previewChannelId, Map<Long, Long> programId2PreviewProgramId) { 546 mProgramId2PreviewProgramId.put(previewChannelId, programId2PreviewProgramId); 547 } 548 549 public void removePreviewProgramIds(long previewChannelId) { 550 mProgramId2PreviewProgramId.remove(previewChannelId); 551 } 552 } 553 554 /** A utils class for preview data. */ 555 public static final class PreviewDataUtils { 556 /** Creates a preview channel. */ 557 public static android.support.media.tv.Channel createPreviewChannel( 558 Context context, @PreviewChannelType long previewChannelType) { 559 if (previewChannelType == TYPE_RECORDED_PROGRAM_PREVIEW_CHANNEL) { 560 return createRecordedProgramPreviewChannel(context, previewChannelType); 561 } 562 return createDefaultPreviewChannel(context, previewChannelType); 563 } 564 565 private static android.support.media.tv.Channel createDefaultPreviewChannel( 566 Context context, @PreviewChannelType long previewChannelType) { 567 android.support.media.tv.Channel.Builder builder = 568 new android.support.media.tv.Channel.Builder(); 569 CharSequence appLabel = 570 context.getApplicationInfo().loadLabel(context.getPackageManager()); 571 CharSequence appDescription = 572 context.getApplicationInfo().loadDescription(context.getPackageManager()); 573 builder.setType(TvContract.Channels.TYPE_PREVIEW) 574 .setDisplayName(appLabel == null ? null : appLabel.toString()) 575 .setDescription(appDescription == null ? null : appDescription.toString()) 576 .setAppLinkIntentUri(TvContract.Channels.CONTENT_URI) 577 .setInternalProviderFlag1(previewChannelType); 578 return builder.build(); 579 } 580 581 private static android.support.media.tv.Channel createRecordedProgramPreviewChannel( 582 Context context, @PreviewChannelType long previewChannelType) { 583 android.support.media.tv.Channel.Builder builder = 584 new android.support.media.tv.Channel.Builder(); 585 builder.setType(TvContract.Channels.TYPE_PREVIEW) 586 .setDisplayName( 587 context.getResources() 588 .getString(R.string.recorded_programs_preview_channel)) 589 .setAppLinkIntentUri(TvContract.Channels.CONTENT_URI) 590 .setInternalProviderFlag1(previewChannelType); 591 return builder.build(); 592 } 593 594 /** Creates a preview program. */ 595 public static PreviewProgram createPreviewProgramFromContent( 596 PreviewProgramContent program) { 597 PreviewProgram.Builder builder = new PreviewProgram.Builder(); 598 builder.setChannelId(program.getPreviewChannelId()) 599 .setType(program.getType()) 600 .setLive(program.getLive()) 601 .setTitle(program.getTitle()) 602 .setDescription(program.getDescription()) 603 .setPosterArtUri(program.getPosterArtUri()) 604 .setIntentUri(program.getIntentUri()) 605 .setPreviewVideoUri(program.getPreviewVideoUri()) 606 .setInternalProviderId(Long.toString(program.getId())) 607 .setContentId(program.getIntentUri().toString()); 608 return builder.build(); 609 } 610 611 /** Appends query parameters to a Uri. */ 612 public static Uri addQueryParamToUri(Uri uri, Pair<String, String> param) { 613 return uri.buildUpon().appendQueryParameter(param.first, param.second).build(); 614 } 615 } 616 } 617