1 /* 2 * Copyright (C) 2022 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.carrierdefaultapp; 17 18 import android.annotation.NonNull; 19 import android.annotation.Nullable; 20 import android.app.Notification; 21 import android.app.NotificationChannel; 22 import android.app.NotificationManager; 23 import android.app.PendingIntent; 24 import android.content.BroadcastReceiver; 25 import android.content.ComponentName; 26 import android.content.Context; 27 import android.content.Intent; 28 import android.content.res.Configuration; 29 import android.content.res.Resources; 30 import android.graphics.drawable.Icon; 31 import android.os.LocaleList; 32 import android.os.SystemProperties; 33 import android.os.UserHandle; 34 import android.telephony.AnomalyReporter; 35 import android.telephony.SubscriptionManager; 36 import android.telephony.TelephonyManager; 37 import android.text.TextUtils; 38 import android.util.Log; 39 import android.webkit.URLUtil; 40 import android.webkit.WebView; 41 42 import com.android.internal.annotations.VisibleForTesting; 43 import com.android.phone.slice.SlicePurchaseController; 44 45 import java.net.MalformedURLException; 46 import java.net.URISyntaxException; 47 import java.net.URL; 48 import java.util.HashMap; 49 import java.util.Locale; 50 import java.util.Map; 51 import java.util.UUID; 52 53 /** 54 * The SlicePurchaseBroadcastReceiver listens for 55 * {@link SlicePurchaseController#ACTION_START_SLICE_PURCHASE_APP} from the SlicePurchaseController 56 * in the phone process to start the slice purchase application. It displays the performance boost 57 * notification to the user and will start the {@link SlicePurchaseActivity} to display the 58 * {@link WebView} to purchase performance boosts from the user's carrier. 59 */ 60 public class SlicePurchaseBroadcastReceiver extends BroadcastReceiver{ 61 private static final String TAG = "SlicePurchaseBroadcastReceiver"; 62 63 /** 64 * UUID to report an anomaly when receiving a PendingIntent from an application or process 65 * other than the Phone process. 66 */ 67 private static final String UUID_BAD_PENDING_INTENT = "c360246e-95dc-4abf-9dc1-929a76cd7e53"; 68 69 /** Channel ID for the performance boost notification. */ 70 private static final String PERFORMANCE_BOOST_NOTIFICATION_CHANNEL_ID = "performance_boost"; 71 /** Tag for the performance boost notification. */ 72 public static final String PERFORMANCE_BOOST_NOTIFICATION_TAG = "SlicePurchaseApp.Notification"; 73 /** 74 * Action for when the user clicks the "Not now" button on the performance boost notification. 75 */ 76 private static final String ACTION_NOTIFICATION_CANCELED = 77 "com.android.phone.slice.action.NOTIFICATION_CANCELED"; 78 79 /** 80 * A map of Intents sent by {@link SlicePurchaseController} for each capability. 81 * If this map contains an Intent for a given capability, the performance boost notification to 82 * purchase the capability is visible to the user. 83 * If this map does not contain an Intent for a given capability, either the capability was 84 * never requested or the {@link SlicePurchaseActivity} is visible to the user. 85 * An Intent is added to this map when the performance boost notification is displayed to the 86 * user and removed from the map when the notification is canceled. 87 */ 88 private static final Map<Integer, Intent> sIntents = new HashMap<>(); 89 90 /** 91 * Cancel the performance boost notification for the given capability and 92 * remove the corresponding notification intent from the map. 93 * 94 * @param context The context to cancel the notification in. 95 * @param capability The premium capability to cancel the notification for. 96 */ cancelNotification(@onNull Context context, @TelephonyManager.PremiumCapability int capability)97 public static void cancelNotification(@NonNull Context context, 98 @TelephonyManager.PremiumCapability int capability) { 99 context.getSystemService(NotificationManager.class).cancelAsUser( 100 PERFORMANCE_BOOST_NOTIFICATION_TAG, capability, UserHandle.ALL); 101 sIntents.remove(capability); 102 } 103 104 /** 105 * Send the PendingIntent containing the corresponding slice purchase application response. 106 * 107 * @param intent The Intent containing the PendingIntent extra. 108 * @param extra The extra to get the PendingIntent to send. 109 */ sendSlicePurchaseAppResponse(@onNull Intent intent, @NonNull String extra)110 public static void sendSlicePurchaseAppResponse(@NonNull Intent intent, @NonNull String extra) { 111 PendingIntent pendingIntent = intent.getParcelableExtra(extra, PendingIntent.class); 112 if (pendingIntent == null) { 113 loge("PendingIntent does not exist for extra: " + extra); 114 return; 115 } 116 try { 117 pendingIntent.send(); 118 } catch (PendingIntent.CanceledException e) { 119 loge("Unable to send " + getPendingIntentType(extra) + " intent: " + e); 120 } 121 } 122 123 /** 124 * Send the PendingIntent containing the corresponding slice purchase application response 125 * with additional data. 126 * 127 * @param context The Context to use to send the PendingIntent. 128 * @param intent The Intent containing the PendingIntent extra. 129 * @param extra The extra to get the PendingIntent to send. 130 * @param data The Intent containing additional data to send with the PendingIntent. 131 */ sendSlicePurchaseAppResponseWithData(@onNull Context context, @NonNull Intent intent, @NonNull String extra, @NonNull Intent data)132 public static void sendSlicePurchaseAppResponseWithData(@NonNull Context context, 133 @NonNull Intent intent, @NonNull String extra, @NonNull Intent data) { 134 PendingIntent pendingIntent = intent.getParcelableExtra(extra, PendingIntent.class); 135 if (pendingIntent == null) { 136 loge("PendingIntent does not exist for extra: " + extra); 137 return; 138 } 139 try { 140 pendingIntent.send(context, 0 /* unused */, data); 141 } catch (PendingIntent.CanceledException e) { 142 loge("Unable to send " + getPendingIntentType(extra) + " intent: " + e); 143 } 144 } 145 146 /** 147 * Check whether the Intent is valid and can be used to complete purchases in the slice purchase 148 * application. This checks that all necessary extras exist and that the values are valid. 149 * 150 * @param intent The intent to check. 151 * @return {@code true} if the intent is valid and {@code false} otherwise. 152 */ isIntentValid(@onNull Intent intent)153 public static boolean isIntentValid(@NonNull Intent intent) { 154 int phoneId = intent.getIntExtra(SlicePurchaseController.EXTRA_PHONE_ID, 155 SubscriptionManager.INVALID_PHONE_INDEX); 156 if (phoneId == SubscriptionManager.INVALID_PHONE_INDEX) { 157 loge("isIntentValid: invalid phone index: " + phoneId); 158 return false; 159 } 160 161 int subId = intent.getIntExtra(SlicePurchaseController.EXTRA_SUB_ID, 162 SubscriptionManager.INVALID_SUBSCRIPTION_ID); 163 if (subId == SubscriptionManager.INVALID_SUBSCRIPTION_ID) { 164 loge("isIntentValid: invalid subscription ID: " + subId); 165 return false; 166 } 167 168 int capability = intent.getIntExtra(SlicePurchaseController.EXTRA_PREMIUM_CAPABILITY, 169 SlicePurchaseController.PREMIUM_CAPABILITY_INVALID); 170 if (capability == SlicePurchaseController.PREMIUM_CAPABILITY_INVALID) { 171 loge("isIntentValid: invalid premium capability: " + capability); 172 return false; 173 } 174 175 String purchaseUrl = intent.getStringExtra(SlicePurchaseController.EXTRA_PURCHASE_URL); 176 String userData = intent.getStringExtra(SlicePurchaseController.EXTRA_USER_DATA); 177 String contentsType = intent.getStringExtra(SlicePurchaseController.EXTRA_CONTENTS_TYPE); 178 if (getPurchaseUrl(purchaseUrl, userData, TextUtils.isEmpty(contentsType)) == null) { 179 loge("isIntentValid: invalid purchase URL: " + purchaseUrl); 180 return false; 181 } 182 183 String carrier = intent.getStringExtra(SlicePurchaseController.EXTRA_CARRIER); 184 if (TextUtils.isEmpty(carrier)) { 185 loge("isIntentValid: empty carrier: " + carrier); 186 return false; 187 } 188 189 return isPendingIntentValid(intent, SlicePurchaseController.EXTRA_INTENT_CANCELED) 190 && isPendingIntentValid(intent, SlicePurchaseController.EXTRA_INTENT_CARRIER_ERROR) 191 && isPendingIntentValid(intent, SlicePurchaseController.EXTRA_INTENT_REQUEST_FAILED) 192 && isPendingIntentValid(intent, 193 SlicePurchaseController.EXTRA_INTENT_NOT_DEFAULT_DATA_SUBSCRIPTION) 194 && isPendingIntentValid(intent, 195 SlicePurchaseController.EXTRA_INTENT_NOTIFICATIONS_DISABLED) 196 && isPendingIntentValid(intent, SlicePurchaseController.EXTRA_INTENT_SUCCESS) 197 && isPendingIntentValid(intent, 198 SlicePurchaseController.EXTRA_INTENT_NOTIFICATION_SHOWN); 199 } 200 201 /** 202 * Get the {@link URL} from the given purchase URL String and user data, if it is valid. 203 * 204 * @param purchaseUrl The purchase URL String to use to create the URL. 205 * @param userData The user data parameter from the entitlement server. 206 * @param shouldAppendUserData If this is {@code true} and the {@code userData} exists, 207 * the {@code userData} should be appended to the {@code purchaseUrl} to create the URL. 208 * If this is false, only the {@code purchaseUrl} should be used and the {@code userData} 209 * will be sent as data to the POST request instead. 210 * @return The URL from the given purchase URL and user data or {@code null} if it is invalid. 211 */ getPurchaseUrl(@ullable String purchaseUrl, @Nullable String userData, boolean shouldAppendUserData)212 @Nullable public static URL getPurchaseUrl(@Nullable String purchaseUrl, 213 @Nullable String userData, boolean shouldAppendUserData) { 214 if (purchaseUrl == null) { 215 return null; 216 } 217 // Only append user data if it exists, otherwise just return the purchase URL 218 if (!shouldAppendUserData || TextUtils.isEmpty(userData)) { 219 return getPurchaseUrl(purchaseUrl); 220 } 221 URL url = getPurchaseUrl(purchaseUrl + "?" + userData); 222 if (url == null) { 223 url = getPurchaseUrl(purchaseUrl); 224 } 225 return url; 226 } 227 228 /** 229 * Get the {@link URL} from the given purchase URL String, if it is valid. 230 * 231 * @param purchaseUrl The purchase URL String to use to create the URL. 232 * @return The purchase URL from the given String or {@code null} if it is invalid. 233 */ getPurchaseUrl(@ullable String purchaseUrl)234 @Nullable private static URL getPurchaseUrl(@Nullable String purchaseUrl) { 235 if (!URLUtil.isValidUrl(purchaseUrl)) { 236 return null; 237 } 238 if (URLUtil.isAssetUrl(purchaseUrl) 239 && !purchaseUrl.equals(SlicePurchaseController.SLICE_PURCHASE_TEST_FILE)) { 240 return null; 241 } 242 URL url = null; 243 try { 244 url = new URL(purchaseUrl); 245 url.toURI(); 246 } catch (MalformedURLException | URISyntaxException e) { 247 loge("Invalid purchase URL: " + purchaseUrl + ", " + e); 248 } 249 return url; 250 } 251 isPendingIntentValid(@onNull Intent intent, @NonNull String extra)252 private static boolean isPendingIntentValid(@NonNull Intent intent, @NonNull String extra) { 253 String intentType = getPendingIntentType(extra); 254 PendingIntent pendingIntent = intent.getParcelableExtra(extra, PendingIntent.class); 255 if (pendingIntent == null) { 256 loge("isPendingIntentValid: " + intentType + " intent not found."); 257 return false; 258 } 259 String creatorPackage = pendingIntent.getCreatorPackage(); 260 if (!creatorPackage.equals(TelephonyManager.PHONE_PROCESS_NAME)) { 261 String logStr = "isPendingIntentValid: " + intentType + " intent was created by " 262 + creatorPackage + " instead of the phone process."; 263 loge(logStr); 264 AnomalyReporter.reportAnomaly(UUID.fromString(UUID_BAD_PENDING_INTENT), logStr); 265 return false; 266 } 267 if (!pendingIntent.isBroadcast()) { 268 loge("isPendingIntentValid: " + intentType + " intent is not a broadcast."); 269 return false; 270 } 271 return true; 272 } 273 getPendingIntentType(@onNull String extra)274 @NonNull private static String getPendingIntentType(@NonNull String extra) { 275 switch (extra) { 276 case SlicePurchaseController.EXTRA_INTENT_CANCELED: return "canceled"; 277 case SlicePurchaseController.EXTRA_INTENT_CARRIER_ERROR: return "carrier error"; 278 case SlicePurchaseController.EXTRA_INTENT_REQUEST_FAILED: return "request failed"; 279 case SlicePurchaseController.EXTRA_INTENT_NOT_DEFAULT_DATA_SUBSCRIPTION: 280 return "not default data subscription"; 281 case SlicePurchaseController.EXTRA_INTENT_NOTIFICATIONS_DISABLED: 282 return "notifications disabled"; 283 case SlicePurchaseController.EXTRA_INTENT_SUCCESS: return "success"; 284 case SlicePurchaseController.EXTRA_INTENT_NOTIFICATION_SHOWN: 285 return "notification shown"; 286 default: { 287 loge("Unknown pending intent extra: " + extra); 288 return "unknown(" + extra + ")"; 289 } 290 } 291 } 292 293 @Override onReceive(@onNull Context context, @NonNull Intent intent)294 public void onReceive(@NonNull Context context, @NonNull Intent intent) { 295 logd("onReceive intent: " + intent.getAction()); 296 switch (intent.getAction()) { 297 case Intent.ACTION_LOCALE_CHANGED: 298 onLocaleChanged(context); 299 break; 300 case SlicePurchaseController.ACTION_START_SLICE_PURCHASE_APP: 301 onDisplayPerformanceBoostNotification(context, intent, false); 302 break; 303 case SlicePurchaseController.ACTION_SLICE_PURCHASE_APP_RESPONSE_TIMEOUT: 304 onTimeout(context, intent); 305 break; 306 case ACTION_NOTIFICATION_CANCELED: 307 onUserCanceled(context, intent); 308 break; 309 default: 310 loge("Received unknown action: " + intent.getAction()); 311 } 312 } 313 onLocaleChanged(@onNull Context context)314 private void onLocaleChanged(@NonNull Context context) { 315 if (sIntents.isEmpty()) return; 316 317 for (int capability : new int[]{TelephonyManager.PREMIUM_CAPABILITY_PRIORITIZE_LATENCY}) { 318 if (sIntents.get(capability) != null) { 319 // Notification is active -- update notification for new locale 320 context.getSystemService(NotificationManager.class).cancelAsUser( 321 PERFORMANCE_BOOST_NOTIFICATION_TAG, capability, UserHandle.ALL); 322 onDisplayPerformanceBoostNotification(context, sIntents.get(capability), true); 323 } 324 } 325 } 326 onDisplayPerformanceBoostNotification(@onNull Context context, @NonNull Intent intent, boolean localeChanged)327 private void onDisplayPerformanceBoostNotification(@NonNull Context context, 328 @NonNull Intent intent, boolean localeChanged) { 329 if (!localeChanged && !isIntentValid(intent)) { 330 sendSlicePurchaseAppResponse(intent, 331 SlicePurchaseController.EXTRA_INTENT_REQUEST_FAILED); 332 return; 333 } 334 335 Resources res = getResources(context); 336 NotificationManager notificationManager = 337 context.getSystemService(NotificationManager.class); 338 NotificationChannel channel = notificationManager.getNotificationChannel( 339 PERFORMANCE_BOOST_NOTIFICATION_CHANNEL_ID); 340 if (channel == null) { 341 channel = new NotificationChannel( 342 PERFORMANCE_BOOST_NOTIFICATION_CHANNEL_ID, 343 res.getString(R.string.performance_boost_notification_channel), 344 NotificationManager.IMPORTANCE_DEFAULT); 345 // CarrierDefaultApp notifications are unblockable by default. 346 // Make this channel blockable to allow users to disable notifications posted to this 347 // channel without affecting other notifications in this application. 348 channel.setBlockable(true); 349 context.getSystemService(NotificationManager.class).createNotificationChannel(channel); 350 } else if (localeChanged) { 351 // If the channel already exists but the locale has changed, update the channel name. 352 channel.setName(res.getString(R.string.performance_boost_notification_channel)); 353 } 354 355 boolean channelNotificationsDisabled = 356 channel.getImportance() == NotificationManager.IMPORTANCE_NONE; 357 if (channelNotificationsDisabled || !notificationManager.areNotificationsEnabled()) { 358 // If notifications are disabled for the app or channel, fail the purchase request. 359 logd("Purchase request failed because notifications are disabled for the " 360 + (channelNotificationsDisabled ? "channel." : "application.")); 361 sendSlicePurchaseAppResponse(intent, 362 SlicePurchaseController.EXTRA_INTENT_NOTIFICATIONS_DISABLED); 363 return; 364 } 365 366 String carrier = intent.getStringExtra(SlicePurchaseController.EXTRA_CARRIER); 367 Notification notification = 368 new Notification.Builder(context, PERFORMANCE_BOOST_NOTIFICATION_CHANNEL_ID) 369 .setContentTitle(res.getString( 370 R.string.performance_boost_notification_title)) 371 .setContentText(String.format(res.getString( 372 R.string.performance_boost_notification_detail), carrier)) 373 .setSmallIcon(R.drawable.ic_performance_boost) 374 .setContentIntent(createContentIntent(context, intent, 1)) 375 .setDeleteIntent(intent.getParcelableExtra( 376 SlicePurchaseController.EXTRA_INTENT_CANCELED, PendingIntent.class)) 377 // Add an action for the "Not now" button, which has the same behavior as 378 // the user canceling or closing the notification. 379 .addAction(new Notification.Action.Builder( 380 Icon.createWithResource(context, R.drawable.ic_performance_boost), 381 res.getString( 382 R.string.performance_boost_notification_button_not_now), 383 createCanceledIntent(context, intent)).build()) 384 // Add an action for the "Manage" button, which has the same behavior as 385 // the user clicking on the notification. 386 .addAction(new Notification.Action.Builder( 387 Icon.createWithResource(context, R.drawable.ic_performance_boost), 388 res.getString( 389 R.string.performance_boost_notification_button_manage), 390 createContentIntent(context, intent, 2)).build()) 391 .build(); 392 393 int capability = intent.getIntExtra(SlicePurchaseController.EXTRA_PREMIUM_CAPABILITY, 394 SlicePurchaseController.PREMIUM_CAPABILITY_INVALID); 395 logd((localeChanged ? "Update" : "Display") 396 + " the performance boost notification for capability " 397 + TelephonyManager.convertPremiumCapabilityToString(capability)); 398 context.getSystemService(NotificationManager.class).notifyAsUser( 399 PERFORMANCE_BOOST_NOTIFICATION_TAG, capability, notification, UserHandle.ALL); 400 if (!localeChanged) { 401 sIntents.put(capability, intent); 402 sendSlicePurchaseAppResponse(intent, 403 SlicePurchaseController.EXTRA_INTENT_NOTIFICATION_SHOWN); 404 } 405 } 406 407 /** 408 * Get the {@link Resources} for the current locale. 409 * 410 * @param context The context to get the resources in. 411 * 412 * @return The resources in the current locale. 413 */ 414 @VisibleForTesting getResources(@onNull Context context)415 @NonNull public Resources getResources(@NonNull Context context) { 416 Resources resources = context.getResources(); 417 Configuration config = resources.getConfiguration(); 418 config.setLocale(getCurrentLocale()); 419 return new Resources(resources.getAssets(), resources.getDisplayMetrics(), config); 420 } 421 422 /** 423 * Get the current {@link Locale} from the system property {@code persist.sys.locale}. 424 * 425 * @return The user's default/preferred language. 426 */ 427 @VisibleForTesting getCurrentLocale()428 @NonNull public Locale getCurrentLocale() { 429 String languageTag = SystemProperties.get("persist.sys.locale"); 430 if (TextUtils.isEmpty(languageTag)) { 431 return LocaleList.getAdjustedDefault().get(0); 432 } 433 return Locale.forLanguageTag(languageTag); 434 } 435 436 /** 437 * Create the intent for when the user clicks on the "Manage" button on the performance boost 438 * notification or the notification itself. This will open {@link SlicePurchaseActivity}. 439 * 440 * @param context The Context to create the intent for. 441 * @param intent The source Intent used to launch the slice purchase application. 442 * @param requestCode The request code for the PendingIntent. 443 * 444 * @return The intent to start {@link SlicePurchaseActivity}. 445 */ 446 @VisibleForTesting createContentIntent(@onNull Context context, @NonNull Intent intent, int requestCode)447 @NonNull public PendingIntent createContentIntent(@NonNull Context context, 448 @NonNull Intent intent, int requestCode) { 449 Intent i = new Intent(context, SlicePurchaseActivity.class); 450 i.setComponent(ComponentName.unflattenFromString( 451 "com.android.carrierdefaultapp/.SlicePurchaseActivity")); 452 i.setFlags(Intent.FLAG_ACTIVITY_BROUGHT_TO_FRONT 453 | Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); 454 i.putExtras(intent); 455 return PendingIntent.getActivityAsUser(context, requestCode, i, 456 PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_MUTABLE, null /* options */, 457 UserHandle.CURRENT); 458 } 459 460 /** 461 * Create the canceled intent for when the user clicks the "Not now" button on the performance 462 * boost notification. This will send {@link #ACTION_NOTIFICATION_CANCELED} and has the same 463 * behavior as if the user had canceled or removed the notification. 464 * 465 * @param context The Context to create the intent for. 466 * @param intent The source Intent used to launch the slice purchase application. 467 * 468 * @return The canceled intent. 469 */ 470 @VisibleForTesting createCanceledIntent(@onNull Context context, @NonNull Intent intent)471 @NonNull public PendingIntent createCanceledIntent(@NonNull Context context, 472 @NonNull Intent intent) { 473 Intent i = new Intent(ACTION_NOTIFICATION_CANCELED); 474 i.setComponent(ComponentName.unflattenFromString( 475 "com.android.carrierdefaultapp/.SlicePurchaseBroadcastReceiver")); 476 i.putExtras(intent); 477 return PendingIntent.getBroadcast(context, 0, i, 478 PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_MUTABLE); 479 } 480 onTimeout(@onNull Context context, @NonNull Intent intent)481 private void onTimeout(@NonNull Context context, @NonNull Intent intent) { 482 int capability = intent.getIntExtra(SlicePurchaseController.EXTRA_PREMIUM_CAPABILITY, 483 SlicePurchaseController.PREMIUM_CAPABILITY_INVALID); 484 logd("Purchase capability " + TelephonyManager.convertPremiumCapabilityToString(capability) 485 + " timed out."); 486 if (sIntents.get(capability) != null) { 487 // Notification is still active -- cancel pending notification 488 logd("Closing performance boost notification since the user did not respond in time."); 489 cancelNotification(context, capability); 490 } else { 491 // SlicePurchaseActivity is still active -- ignore timer 492 logd("Ignoring timeout since the SlicePurchaseActivity is still active."); 493 } 494 } 495 onUserCanceled(@onNull Context context, @NonNull Intent intent)496 private void onUserCanceled(@NonNull Context context, @NonNull Intent intent) { 497 if (!isIntentValid(intent)) { 498 loge("Ignoring onUserCanceled called with invalid intent."); 499 return; 500 } 501 int capability = intent.getIntExtra(SlicePurchaseController.EXTRA_PREMIUM_CAPABILITY, 502 SlicePurchaseController.PREMIUM_CAPABILITY_INVALID); 503 logd("onUserCanceled: " + TelephonyManager.convertPremiumCapabilityToString(capability)); 504 cancelNotification(context, capability); 505 sendSlicePurchaseAppResponse(intent, SlicePurchaseController.EXTRA_INTENT_CANCELED); 506 } 507 logd(String s)508 private static void logd(String s) { 509 Log.d(TAG, s); 510 } 511 loge(String s)512 private static void loge(String s) { 513 Log.e(TAG, s); 514 } 515 } 516