1 /* 2 3 * Copyright (C) 2014 The Android Open Source Project 4 * 5 * Licensed under the Apache License, Version 2.0 (the "License"); 6 * you may not use this file except in compliance with the License. 7 * You may obtain a copy of the License at 8 * 9 * http://www.apache.org/licenses/LICENSE-2.0 10 * 11 * Unless required by applicable law or agreed to in writing, software 12 * distributed under the License is distributed on an "AS IS" BASIS, 13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 * See the License for the specific language governing permissions and 15 * limitations under the License. 16 */ 17 18 package com.android.cts.verifier.sensors.base; 19 20 import com.android.cts.verifier.R; 21 import com.android.cts.verifier.TestResult; 22 import com.android.cts.verifier.sensors.helpers.SensorFeaturesDeactivator; 23 import com.android.cts.verifier.sensors.reporting.SensorTestDetails; 24 25 import junit.framework.Assert; 26 27 //import android.app.Activity; 28 import com.android.cts.verifier.PassFailButtons; 29 30 import android.content.Context; 31 import android.content.Intent; 32 import android.hardware.cts.helpers.ActivityResultMultiplexedLatch; 33 import android.media.MediaPlayer; 34 import android.opengl.GLSurfaceView; 35 import android.os.Bundle; 36 import android.os.SystemClock; 37 import android.os.Vibrator; 38 import android.provider.Settings; 39 import android.text.TextUtils; 40 import android.text.format.DateUtils; 41 import android.util.Log; 42 import android.view.View; 43 import android.widget.Button; 44 import android.widget.LinearLayout; 45 import android.widget.ScrollView; 46 import android.widget.TextView; 47 48 import java.util.ArrayList; 49 import java.util.concurrent.CountDownLatch; 50 import java.util.concurrent.ExecutorService; 51 import java.util.concurrent.Executors; 52 import java.util.concurrent.TimeUnit; 53 54 /** 55 * A base Activity that is used to build different methods to execute tests inside CtsVerifier. 56 * i.e. CTS tests, and semi-automated CtsVerifier tests. 57 * 58 * This class provides access to the following flow: 59 * Activity set up 60 * Execute tests (implemented by sub-classes) 61 * Activity clean up 62 * 63 * Currently the following class structure is available: 64 * - BaseSensorTestActivity : provides the platform to execute Sensor tests inside 65 * | CtsVerifier, and logging support 66 * | 67 * -- SensorCtsTestActivity : an activity that can be inherited from to wrap a CTS 68 * | sensor test, and execute it inside CtsVerifier 69 * | these tests do not require any operator interaction 70 * | 71 * -- SensorCtsVerifierTestActivity : an activity that can be inherited to write sensor 72 * tests that require operator interaction 73 */ 74 public abstract class BaseSensorTestActivity 75 extends PassFailButtons.Activity 76 implements View.OnClickListener, Runnable, ISensorTestStateContainer { 77 @Deprecated 78 protected static final String LOG_TAG = "SensorTest"; 79 80 protected final Class mTestClass; 81 82 private final int mLayoutId; 83 private final SensorFeaturesDeactivator mSensorFeaturesDeactivator; 84 85 private final ExecutorService mExecutorService = Executors.newSingleThreadExecutor(); 86 private final SensorTestLogger mTestLogger = new SensorTestLogger(); 87 private final ActivityResultMultiplexedLatch mActivityResultMultiplexedLatch = 88 new ActivityResultMultiplexedLatch(); 89 private final ArrayList<CountDownLatch> mWaitForUserLatches = new ArrayList<CountDownLatch>(); 90 91 private ScrollView mLogScrollView; 92 private LinearLayout mLogLayout; 93 private Button mNextButton; 94 private Button mPassButton; 95 private Button mFailButton; 96 97 private GLSurfaceView mGLSurfaceView; 98 private boolean mUsingGlSurfaceView; 99 100 /** 101 * Constructor to be used by subclasses. 102 * 103 * @param testClass The class that contains the tests. It is dependant on test executor 104 * implemented by subclasses. 105 */ BaseSensorTestActivity(Class testClass)106 protected BaseSensorTestActivity(Class testClass) { 107 this(testClass, R.layout.sensor_test); 108 } 109 110 /** 111 * Constructor to be used by subclasses. It allows to provide a custom layout for the test UI. 112 * 113 * @param testClass The class that contains the tests. It is dependant on test executor 114 * implemented by subclasses. 115 * @param layoutId The Id of the layout to use for the test UI. The layout must contain all the 116 * elements in the base layout {@code R.layout.sensor_test}. 117 */ BaseSensorTestActivity(Class testClass, int layoutId)118 protected BaseSensorTestActivity(Class testClass, int layoutId) { 119 mTestClass = testClass; 120 mLayoutId = layoutId; 121 mSensorFeaturesDeactivator = new SensorFeaturesDeactivator(this); 122 } 123 124 @Override onCreate(Bundle savedInstanceState)125 protected void onCreate(Bundle savedInstanceState) { 126 super.onCreate(savedInstanceState); 127 setContentView(mLayoutId); 128 129 mLogScrollView = (ScrollView) findViewById(R.id.log_scroll_view); 130 mLogLayout = (LinearLayout) findViewById(R.id.log_layout); 131 mNextButton = (Button) findViewById(R.id.next_button); 132 mNextButton.setOnClickListener(this); 133 mPassButton = (Button) findViewById(R.id.pass_button); 134 mFailButton = (Button) findViewById(R.id.fail_button); 135 mGLSurfaceView = (GLSurfaceView) findViewById(R.id.gl_surface_view); 136 137 updateNextButton(false /*enabled*/); 138 mExecutorService.execute(this); 139 } 140 141 @Override onDestroy()142 protected void onDestroy() { 143 super.onDestroy(); 144 mExecutorService.shutdownNow(); 145 } 146 147 @Override onPause()148 protected void onPause() { 149 super.onPause(); 150 if (mUsingGlSurfaceView) { 151 mGLSurfaceView.onPause(); 152 } 153 } 154 155 @Override onResume()156 protected void onResume() { 157 super.onResume(); 158 if (mUsingGlSurfaceView) { 159 mGLSurfaceView.onResume(); 160 } 161 } 162 163 @Override onClick(View target)164 public void onClick(View target) { 165 synchronized (mWaitForUserLatches) { 166 for (CountDownLatch latch : mWaitForUserLatches) { 167 latch.countDown(); 168 } 169 mWaitForUserLatches.clear(); 170 } 171 } 172 173 @Override onActivityResult(int requestCode, int resultCode, Intent data)174 protected void onActivityResult(int requestCode, int resultCode, Intent data) { 175 mActivityResultMultiplexedLatch.onActivityResult(requestCode, resultCode); 176 } 177 178 /** 179 * The main execution {@link Thread}. 180 * 181 * This function executes in a background thread, allowing the test run freely behind the 182 * scenes. It provides the following execution hooks: 183 * - Activity SetUp/CleanUp (not available in JUnit) 184 * - executeTests: to implement several execution engines 185 */ 186 @Override run()187 public void run() { 188 long startTimeNs = SystemClock.elapsedRealtimeNanos(); 189 String testName = getTestClassName(); 190 191 SensorTestDetails testDetails; 192 try { 193 mSensorFeaturesDeactivator.requestDeactivationOfFeatures(); 194 testDetails = new SensorTestDetails(testName, SensorTestDetails.ResultCode.PASS); 195 } catch (Throwable e) { 196 testDetails = new SensorTestDetails(testName, "DeactivateSensorFeatures", e); 197 } 198 199 SensorTestDetails.ResultCode resultCode = testDetails.getResultCode(); 200 if (resultCode == SensorTestDetails.ResultCode.SKIPPED) { 201 // this is an invalid state at this point of the test setup 202 throw new IllegalStateException("Deactivation of features cannot skip the test."); 203 } 204 if (resultCode == SensorTestDetails.ResultCode.PASS) { 205 testDetails = executeActivityTests(testName); 206 } 207 208 // we consider all remaining states at this point, because we could have been half way 209 // deactivating features 210 try { 211 mSensorFeaturesDeactivator.requestToRestoreFeatures(); 212 } catch (Throwable e) { 213 testDetails = new SensorTestDetails(testName, "RestoreSensorFeatures", e); 214 } 215 216 mTestLogger.logTestDetails(testDetails); 217 mTestLogger.logExecutionTime(startTimeNs); 218 219 // because we cannot enforce test failures in several devices, set the test UI so the 220 // operator can report the result of the test 221 promptUserToSetResult(testDetails); 222 } 223 224 /** 225 * A general set up routine. It executes only once before the first test case. 226 * 227 * NOTE: implementers must be aware of the interrupted status of the worker thread, and let 228 * {@link InterruptedException} propagate. 229 * 230 * @throws Throwable An exception that denotes the failure of set up. No tests will be executed. 231 */ activitySetUp()232 protected void activitySetUp() throws Throwable {} 233 234 /** 235 * A general clean up routine. It executes upon successful execution of {@link #activitySetUp()} 236 * and after all the test cases. 237 * 238 * NOTE: implementers must be aware of the interrupted status of the worker thread, and handle 239 * it in two cases: 240 * - let {@link InterruptedException} propagate 241 * - if it is invoked with the interrupted status, prevent from showing any UI 242 243 * @throws Throwable An exception that will be logged and ignored, for ease of implementation 244 * by subclasses. 245 */ activityCleanUp()246 protected void activityCleanUp() throws Throwable {} 247 248 /** 249 * Performs the work of executing the tests. 250 * Sub-classes implementing different execution methods implement this method. 251 * 252 * @return A {@link SensorTestDetails} object containing information about the executed tests. 253 */ executeTests()254 protected abstract SensorTestDetails executeTests() throws InterruptedException; 255 256 @Override getTestLogger()257 public SensorTestLogger getTestLogger() { 258 return mTestLogger; 259 } 260 261 @Deprecated appendText(int resId)262 protected void appendText(int resId) { 263 mTestLogger.logInstructions(resId); 264 } 265 266 @Deprecated appendText(String text)267 protected void appendText(String text) { 268 TextAppender textAppender = new TextAppender(R.layout.snsr_instruction); 269 textAppender.setText(text); 270 textAppender.append(); 271 } 272 273 @Deprecated clearText()274 protected void clearText() { 275 this.runOnUiThread(new Runnable() { 276 @Override 277 public void run() { 278 mLogLayout.removeAllViews(); 279 } 280 }); 281 } 282 283 /** 284 * Waits for the operator to acknowledge a requested action. 285 * 286 * @param waitMessageResId The action requested to the operator. 287 */ waitForUser(int waitMessageResId)288 protected void waitForUser(int waitMessageResId) throws InterruptedException { 289 CountDownLatch latch = new CountDownLatch(1); 290 synchronized (mWaitForUserLatches) { 291 mWaitForUserLatches.add(latch); 292 } 293 294 mTestLogger.logInstructions(waitMessageResId); 295 updateNextButton(true); 296 latch.await(); 297 updateNextButton(false); 298 } 299 300 /** 301 * Waits for the operator to acknowledge to begin execution. 302 */ waitForUserToBegin()303 protected void waitForUserToBegin() throws InterruptedException { 304 waitForUser(R.string.snsr_wait_to_begin); 305 } 306 307 /** 308 * {@inheritDoc} 309 */ 310 @Override waitForUserToContinue()311 public void waitForUserToContinue() throws InterruptedException { 312 waitForUser(R.string.snsr_wait_for_user); 313 } 314 315 /** 316 * {@inheritDoc} 317 */ 318 @Override executeActivity(String action)319 public int executeActivity(String action) throws InterruptedException { 320 return executeActivity(new Intent(action)); 321 } 322 323 /** 324 * {@inheritDoc} 325 */ 326 @Override executeActivity(Intent intent)327 public int executeActivity(Intent intent) throws InterruptedException { 328 ActivityResultMultiplexedLatch.Latch latch = mActivityResultMultiplexedLatch.bindThread(); 329 startActivityForResult(intent, latch.getRequestCode()); 330 return latch.await(); 331 } 332 333 /** 334 * Initializes and shows the {@link GLSurfaceView} available to tests. 335 * NOTE: initialization can be performed only once, usually inside {@link #activitySetUp()}. 336 */ initializeGlSurfaceView(final GLSurfaceView.Renderer renderer)337 protected void initializeGlSurfaceView(final GLSurfaceView.Renderer renderer) { 338 runOnUiThread(new Runnable() { 339 @Override 340 public void run() { 341 mGLSurfaceView.setVisibility(View.VISIBLE); 342 mGLSurfaceView.setRenderer(renderer); 343 mUsingGlSurfaceView = true; 344 } 345 }); 346 } 347 348 /** 349 * Closes and hides the {@link GLSurfaceView}. 350 */ closeGlSurfaceView()351 protected void closeGlSurfaceView() { 352 runOnUiThread(new Runnable() { 353 @Override 354 public void run() { 355 if (!mUsingGlSurfaceView) { 356 return; 357 } 358 mGLSurfaceView.setVisibility(View.GONE); 359 mGLSurfaceView.onPause(); 360 mUsingGlSurfaceView = false; 361 } 362 }); 363 } 364 365 /** 366 * Plays a (default) sound as a notification for the operator. 367 */ playSound()368 protected void playSound() throws InterruptedException { 369 MediaPlayer player = MediaPlayer.create(this, Settings.System.DEFAULT_NOTIFICATION_URI); 370 if (player == null) { 371 Log.e(LOG_TAG, "MediaPlayer unavailable."); 372 return; 373 } 374 player.start(); 375 try { 376 Thread.sleep(500); 377 } finally { 378 player.stop(); 379 } 380 } 381 382 /** 383 * Makes the device vibrate for the given amount of time. 384 */ vibrate(int timeInMs)385 protected void vibrate(int timeInMs) { 386 Vibrator vibrator = (Vibrator) getSystemService(Context.VIBRATOR_SERVICE); 387 vibrator.vibrate(timeInMs); 388 } 389 390 /** 391 * Makes the device vibrate following the given pattern. 392 * See {@link Vibrator#vibrate(long[], int)} for more information. 393 */ vibrate(long[] pattern)394 protected void vibrate(long[] pattern) { 395 Vibrator vibrator = (Vibrator) getSystemService(Context.VIBRATOR_SERVICE); 396 vibrator.vibrate(pattern, -1); 397 } 398 399 // TODO: move to sensor assertions assertTimestampSynchronization( long eventTimestamp, long receivedTimestamp, long deltaThreshold, String sensorName)400 protected String assertTimestampSynchronization( 401 long eventTimestamp, 402 long receivedTimestamp, 403 long deltaThreshold, 404 String sensorName) { 405 long timestampDelta = Math.abs(eventTimestamp - receivedTimestamp); 406 String timestampMessage = getString( 407 R.string.snsr_event_time, 408 receivedTimestamp, 409 eventTimestamp, 410 timestampDelta, 411 deltaThreshold, 412 sensorName); 413 Assert.assertTrue(timestampMessage, timestampDelta < deltaThreshold); 414 return timestampMessage; 415 } 416 getTestClassName()417 protected String getTestClassName() { 418 if (mTestClass == null) { 419 return "<unknown>"; 420 } 421 return mTestClass.getName(); 422 } 423 setLogScrollViewListener(View.OnTouchListener listener)424 protected void setLogScrollViewListener(View.OnTouchListener listener) { 425 mLogScrollView.setOnTouchListener(listener); 426 } 427 setTestResult(SensorTestDetails testDetails)428 private void setTestResult(SensorTestDetails testDetails) { 429 // the name here, must be the Activity's name because it is what CtsVerifier expects 430 String name = super.getClass().getName(); 431 String summary = mTestLogger.getOverallSummary(); 432 SensorTestDetails.ResultCode resultCode = testDetails.getResultCode(); 433 switch(resultCode) { 434 case SKIPPED: 435 TestResult.setPassedResult(this, name, summary); 436 break; 437 case PASS: 438 TestResult.setPassedResult(this, name, summary); 439 break; 440 case FAIL: 441 TestResult.setFailedResult(this, name, summary); 442 break; 443 case INTERRUPTED: 444 // do not set a result, just return so the test can complete 445 break; 446 default: 447 throw new IllegalStateException("Unknown ResultCode: " + resultCode); 448 } 449 } 450 executeActivityTests(String testName)451 private SensorTestDetails executeActivityTests(String testName) { 452 SensorTestDetails testDetails; 453 try { 454 activitySetUp(); 455 testDetails = new SensorTestDetails(testName, SensorTestDetails.ResultCode.PASS); 456 } catch (Throwable e) { 457 testDetails = new SensorTestDetails(testName, "ActivitySetUp", e); 458 } 459 460 SensorTestDetails.ResultCode resultCode = testDetails.getResultCode(); 461 if (resultCode == SensorTestDetails.ResultCode.PASS) { 462 // TODO: implement execution filters: 463 // - execute all tests and report results officially 464 // - execute single test or failed tests only 465 try { 466 testDetails = executeTests(); 467 } catch (Throwable e) { 468 // we catch and continue because we have to guarantee a proper clean-up sequence 469 testDetails = new SensorTestDetails(testName, "TestExecution", e); 470 } 471 } 472 473 // clean-up executes for all states, even on SKIPPED and INTERRUPTED there might be some 474 // intermediate state that needs to be taken care of 475 try { 476 activityCleanUp(); 477 } catch (Throwable e) { 478 testDetails = new SensorTestDetails(testName, "ActivityCleanUp", e); 479 } 480 481 return testDetails; 482 } 483 promptUserToSetResult(SensorTestDetails testDetails)484 private void promptUserToSetResult(SensorTestDetails testDetails) { 485 SensorTestDetails.ResultCode resultCode = testDetails.getResultCode(); 486 if (resultCode == SensorTestDetails.ResultCode.FAIL) { 487 mTestLogger.logInstructions(R.string.snsr_test_complete_with_errors); 488 enableTestResultButton( 489 mPassButton, 490 R.string.snsr_pass_on_error, 491 testDetails.cloneAndChangeResultCode(SensorTestDetails.ResultCode.PASS)); 492 enableTestResultButton( 493 mFailButton, 494 R.string.fail_button_text, 495 testDetails.cloneAndChangeResultCode(SensorTestDetails.ResultCode.FAIL)); 496 } else if (resultCode != SensorTestDetails.ResultCode.INTERRUPTED) { 497 mTestLogger.logInstructions(R.string.snsr_test_complete); 498 enableTestResultButton( 499 mPassButton, 500 R.string.pass_button_text, 501 testDetails.cloneAndChangeResultCode(SensorTestDetails.ResultCode.PASS)); 502 } 503 } 504 updateNextButton(final boolean enabled)505 private void updateNextButton(final boolean enabled) { 506 runOnUiThread(new Runnable() { 507 @Override 508 public void run() { 509 mNextButton.setEnabled(enabled); 510 } 511 }); 512 } 513 enableTestResultButton( final Button button, final int textResId, final SensorTestDetails testDetails)514 private void enableTestResultButton( 515 final Button button, 516 final int textResId, 517 final SensorTestDetails testDetails) { 518 final View.OnClickListener listener = new View.OnClickListener() { 519 @Override 520 public void onClick(View v) { 521 setTestResult(testDetails); 522 finish(); 523 } 524 }; 525 526 runOnUiThread(new Runnable() { 527 @Override 528 public void run() { 529 mNextButton.setVisibility(View.GONE); 530 button.setText(textResId); 531 button.setOnClickListener(listener); 532 button.setVisibility(View.VISIBLE); 533 } 534 }); 535 } 536 537 // a logger available until sensor reporting is in place 538 public class SensorTestLogger { 539 private static final String SUMMARY_SEPARATOR = " | "; 540 541 private final StringBuilder mOverallSummaryBuilder = new StringBuilder("\n"); 542 logCustomView(View view)543 public void logCustomView(View view) { 544 new ViewAppender(view).append(); 545 } 546 logTestStart(String testName)547 void logTestStart(String testName) { 548 // TODO: log the sensor information and expected execution time of each test 549 TextAppender textAppender = new TextAppender(R.layout.snsr_test_title); 550 textAppender.setText(testName); 551 textAppender.append(); 552 } 553 logInstructions(int instructionsResId, Object ... params)554 public void logInstructions(int instructionsResId, Object ... params) { 555 TextAppender textAppender = new TextAppender(R.layout.snsr_instruction); 556 textAppender.setText(getString(instructionsResId, params)); 557 textAppender.append(); 558 } 559 logMessage(int messageResId, Object ... params)560 public void logMessage(int messageResId, Object ... params) { 561 TextAppender textAppender = new TextAppender(R.layout.snsr_message); 562 textAppender.setText(getString(messageResId, params)); 563 textAppender.append(); 564 } 565 logWaitForSound()566 public void logWaitForSound() { 567 logInstructions(R.string.snsr_test_play_sound); 568 } 569 logTestDetails(SensorTestDetails testDetails)570 public void logTestDetails(SensorTestDetails testDetails) { 571 String name = testDetails.getName(); 572 String summary = testDetails.getSummary(); 573 SensorTestDetails.ResultCode resultCode = testDetails.getResultCode(); 574 switch (resultCode) { 575 case SKIPPED: 576 logTestSkip(name, summary); 577 break; 578 case PASS: 579 logTestPass(name, summary); 580 break; 581 case FAIL: 582 logTestFail(name, summary); 583 break; 584 case INTERRUPTED: 585 // do nothing, the test was interrupted so do we 586 break; 587 default: 588 throw new IllegalStateException("Unknown ResultCode: " + resultCode); 589 } 590 } 591 logTestPass(String testName, String testSummary)592 void logTestPass(String testName, String testSummary) { 593 testSummary = getValidTestSummary(testSummary, R.string.snsr_test_pass); 594 logTestEnd(R.layout.snsr_success, testSummary); 595 Log.d(LOG_TAG, testSummary); 596 saveResult(testName, SensorTestDetails.ResultCode.PASS, testSummary); 597 } 598 logTestFail(String testName, String testSummary)599 public void logTestFail(String testName, String testSummary) { 600 testSummary = getValidTestSummary(testSummary, R.string.snsr_test_fail); 601 logTestEnd(R.layout.snsr_error, testSummary); 602 Log.e(LOG_TAG, testSummary); 603 saveResult(testName, SensorTestDetails.ResultCode.FAIL, testSummary); 604 } 605 logTestSkip(String testName, String testSummary)606 void logTestSkip(String testName, String testSummary) { 607 testSummary = getValidTestSummary(testSummary, R.string.snsr_test_skipped); 608 logTestEnd(R.layout.snsr_warning, testSummary); 609 Log.i(LOG_TAG, testSummary); 610 saveResult(testName, SensorTestDetails.ResultCode.SKIPPED, testSummary); 611 } 612 getOverallSummary()613 String getOverallSummary() { 614 return mOverallSummaryBuilder.toString(); 615 } 616 logExecutionTime(long startTimeNs)617 void logExecutionTime(long startTimeNs) { 618 if (Thread.currentThread().isInterrupted()) { 619 return; 620 } 621 long executionTimeNs = SystemClock.elapsedRealtimeNanos() - startTimeNs; 622 long executionTimeSec = TimeUnit.NANOSECONDS.toSeconds(executionTimeNs); 623 // TODO: find a way to format times with nanosecond accuracy and longer than 24hrs 624 String formattedElapsedTime = DateUtils.formatElapsedTime(executionTimeSec); 625 logMessage(R.string.snsr_execution_time, formattedElapsedTime); 626 } 627 logTestEnd(int textViewResId, String testSummary)628 private void logTestEnd(int textViewResId, String testSummary) { 629 TextAppender textAppender = new TextAppender(textViewResId); 630 textAppender.setText(testSummary); 631 textAppender.append(); 632 } 633 getValidTestSummary(String testSummary, int defaultSummaryResId)634 private String getValidTestSummary(String testSummary, int defaultSummaryResId) { 635 if (TextUtils.isEmpty(testSummary)) { 636 return getString(defaultSummaryResId); 637 } 638 return testSummary; 639 } 640 saveResult( String testName, SensorTestDetails.ResultCode resultCode, String summary)641 private void saveResult( 642 String testName, 643 SensorTestDetails.ResultCode resultCode, 644 String summary) { 645 mOverallSummaryBuilder.append(testName); 646 mOverallSummaryBuilder.append(SUMMARY_SEPARATOR); 647 mOverallSummaryBuilder.append(resultCode.name()); 648 mOverallSummaryBuilder.append(SUMMARY_SEPARATOR); 649 mOverallSummaryBuilder.append(summary); 650 mOverallSummaryBuilder.append("\n"); 651 } 652 } 653 654 private class ViewAppender { 655 protected final View mView; 656 ViewAppender(View view)657 public ViewAppender(View view) { 658 mView = view; 659 } 660 append()661 public void append() { 662 runOnUiThread(new Runnable() { 663 @Override 664 public void run() { 665 mLogLayout.addView(mView); 666 mLogScrollView.post(new Runnable() { 667 @Override 668 public void run() { 669 mLogScrollView.fullScroll(View.FOCUS_DOWN); 670 } 671 }); 672 } 673 }); 674 } 675 } 676 677 private class TextAppender extends ViewAppender{ 678 private final TextView mTextView; 679 TextAppender(int textViewResId)680 public TextAppender(int textViewResId) { 681 super(getLayoutInflater().inflate(textViewResId, null /* viewGroup */)); 682 mTextView = (TextView) mView; 683 } 684 setText(String text)685 public void setText(String text) { 686 mTextView.setText(text); 687 } 688 setText(int textResId)689 public void setText(int textResId) { 690 mTextView.setText(textResId); 691 } 692 } 693 } 694